From 5e002bec878725e0faef871ed453ccaf6e3ec107 Mon Sep 17 00:00:00 2001 From: Matthew Irish Date: Tue, 2 Jul 2019 16:23:07 -0500 Subject: [PATCH] UI - add delete for the various kmip models (#7015) * add menu-loader component to show menu loading button when the model relationship isPending * list what keys we've got in api-path error * fix spacing issue on error flash * add an action on list-controller that bubbles to the list-route mixin to refresh the route * empty store when creating scopes * don't delete _requestQuery in the loop, do it after * add scope deletion from the scope list * add deleteRecord to kmip adapters * add model-wrap component * delete role from detail page and list * add revoke credentials functionality * fix comment * treat all operations fields specially on kmip roles * adjust kmip role edit form for new fields * fix api-path test * update document blocks for menu-loader and model-wrap components --- ui/app/adapters/kmip/credential.js | 14 +++++++ ui/app/adapters/kmip/role.js | 34 ++++++++++++++++- ui/app/adapters/kmip/scope.js | 4 ++ ui/app/models/kmip/credential.js | 8 +++- ui/app/models/kmip/role.js | 36 +++++++++++++----- ui/app/models/kmip/scope.js | 9 ++++- ui/app/serializers/application.js | 4 +- ui/app/utils/api-path.js | 4 +- ui/lib/core/addon/components/list-item.js | 2 +- ui/lib/core/addon/components/menu-loader.js | 22 +++++++++++ ui/lib/core/addon/components/model-wrap.js | 37 +++++++++++++++++++ ui/lib/core/addon/mixins/list-controller.js | 4 ++ .../templates/components/menu-loader.hbs | 7 ++++ .../addon/templates/components/model-wrap.hbs | 1 + ui/lib/core/app/components/menu-loader.js | 1 + ui/lib/core/app/components/model-wrap.js | 1 + .../addon/components/edit-form-kmip-role.js | 2 +- .../kmip/addon/routes/scope/roles/create.js | 1 + ui/lib/kmip/addon/routes/scopes/create.js | 3 ++ .../components/edit-form-kmip-role.hbs | 10 ++++- .../addon/templates/credentials/index.hbs | 23 ++++++++++++ .../kmip/addon/templates/credentials/show.hbs | 23 ++++++++++++ ui/lib/kmip/addon/templates/role.hbs | 33 ++++++++++++++--- ui/lib/kmip/addon/templates/scope/roles.hbs | 29 +++++++++++++++ ui/lib/kmip/addon/templates/scopes/index.hbs | 22 +++++++++++ .../components/edit-form-kmip-role-test.js | 28 +++++++++++--- ui/tests/unit/adapters/kmip/role-test.js | 32 ++++++++++++++-- ui/tests/unit/utils/api-path-test.js | 2 +- 28 files changed, 360 insertions(+), 36 deletions(-) create mode 100644 ui/lib/core/addon/components/menu-loader.js create mode 100644 ui/lib/core/addon/components/model-wrap.js create mode 100644 ui/lib/core/addon/templates/components/menu-loader.hbs create mode 100644 ui/lib/core/addon/templates/components/model-wrap.hbs create mode 100644 ui/lib/core/app/components/menu-loader.js create mode 100644 ui/lib/core/app/components/model-wrap.js diff --git a/ui/app/adapters/kmip/credential.js b/ui/app/adapters/kmip/credential.js index b485d1b26c..d6c6bb8d72 100644 --- a/ui/app/adapters/kmip/credential.js +++ b/ui/app/adapters/kmip/credential.js @@ -13,4 +13,18 @@ export default BaseAdapter.extend({ return model; }); }, + + deleteRecord(store, type, snapshot) { + let url = this._url(type.modelName, { + backend: snapshot.record.backend, + scope: snapshot.record.scope, + role: snapshot.record.role, + }); + url = `${url}/revoke`; + return this.ajax(url, 'POST', { + data: { + serial_number: snapshot.id, + }, + }); + }, }); diff --git a/ui/app/adapters/kmip/role.js b/ui/app/adapters/kmip/role.js index dc7a9e60f2..1f76a0400c 100644 --- a/ui/app/adapters/kmip/role.js +++ b/ui/app/adapters/kmip/role.js @@ -1,4 +1,6 @@ import BaseAdapter from './base'; +import { decamelize } from '@ember/string'; +import { getProperties } from '@ember/object'; export default BaseAdapter.extend({ createRecord(store, type, snapshot) { @@ -15,19 +17,47 @@ export default BaseAdapter.extend({ return { id: name, name, + backend: snapshot.record.backend, + scope: snapshot.record.scope, }; }); }, + deleteRecord(store, type, snapshot) { + let name = snapshot.id || snapshot.attr('name'); + let url = this._url( + type.modelName, + { + backend: snapshot.record.backend, + scope: snapshot.record.scope, + }, + name + ); + return this.ajax(url, 'DELETE'); + }, + serialize(snapshot) { // the endpoint here won't allow sending `operation_all` and `operation_none` at the same time or with // other values, so we manually check for them and send an abbreviated object let json = snapshot.serialize(); + let keys = snapshot.record.nonOperationFields.map(decamelize); + let nonOperationFields = getProperties(json, keys); + for (let field in nonOperationFields) { + if (nonOperationFields[field] == null) { + delete nonOperationFields[field]; + } + } if (json.operation_all) { - return { operation_all: true }; + return { + operation_all: true, + ...nonOperationFields, + }; } if (json.operation_none) { - return { operation_none: true }; + return { + operation_none: true, + ...nonOperationFields, + }; } delete json.operation_none; delete json.operation_all; diff --git a/ui/app/adapters/kmip/scope.js b/ui/app/adapters/kmip/scope.js index db3f27374b..f2d6a02e06 100644 --- a/ui/app/adapters/kmip/scope.js +++ b/ui/app/adapters/kmip/scope.js @@ -12,4 +12,8 @@ export default BaseAdapter.extend({ } ); }, + + deleteRecord(store, type, snapshot) { + return this.ajax(this._url(type.modelName, { backend: snapshot.record.backend }, snapshot.id), 'DELETE'); + }, }); diff --git a/ui/app/models/kmip/credential.js b/ui/app/models/kmip/credential.js index bf24c97418..a8f9f03201 100644 --- a/ui/app/models/kmip/credential.js +++ b/ui/app/models/kmip/credential.js @@ -2,8 +2,10 @@ import DS from 'ember-data'; import fieldToAttrs from 'vault/utils/field-to-attrs'; import { computed } from '@ember/object'; const { attr } = DS; +import apiPath from 'vault/utils/api-path'; +import attachCapabilities from 'vault/lib/attach-capabilities'; -export default DS.Model.extend({ +const Model = DS.Model.extend({ backend: attr({ readOnly: true }), scope: attr({ readOnly: true }), role: attr({ readOnly: true }), @@ -28,3 +30,7 @@ export default DS.Model.extend({ return fieldToAttrs(this, groups); }), }); + +export default attachCapabilities(Model, { + deletePath: apiPath`${'backend'}/scope/${'scope'}/role/${'role'}/credentials/revoke`, +}); diff --git a/ui/app/models/kmip/role.js b/ui/app/models/kmip/role.js index 6190a36755..e68ce378b4 100644 --- a/ui/app/models/kmip/role.js +++ b/ui/app/models/kmip/role.js @@ -6,24 +6,40 @@ import apiPath from 'vault/utils/api-path'; import attachCapabilities from 'vault/lib/attach-capabilities'; const { attr } = DS; -const Model = DS.Model.extend({ +export const COMPUTEDS = { + operationFields: computed('newFields', function() { + return this.newFields.filter(key => key.startsWith('operation')); + }), + + operationFieldsWithoutSpecial: computed('operationFields', function() { + return this.operationFields.slice().removeObjects(['operationAll', 'operationNone']); + }), + + nonOperationFields: computed('operationFields', function() { + let excludeFields = ['role'].concat(this.operationFields); + return this.newFields.slice().removeObjects(excludeFields); + }), +}; + +const Model = DS.Model.extend(COMPUTEDS, { useOpenAPI: true, backend: attr({ readOnly: true }), scope: attr({ readOnly: true }), + name: attr({ readOnly: true }), getHelpUrl(path) { return `/v1/${path}/scope/example/role/example?help=1`; }, - - name: attr({ readOnly: true }), - fieldGroups: computed(function() { - let fields = this.newFields.without('role'); - const groups = [{ 'Allowed Operations': fields }]; - return fieldToAttrs(this, groups); + fieldGroups: computed('fields', 'nonOperationFields', function() { + const groups = [{ default: this.nonOperationFields }, { 'Allowed Operations': this.operationFields }]; + let ret = fieldToAttrs(this, groups); + return ret; }), - fields: computed(function() { - let fields = this.newFields.removeObjects(['role', 'operationAll', 'operationNone']); - return expandAttributeMeta(this, fields); + operationFormFields: computed('operationFieldsWithoutSpecial', function() { + return expandAttributeMeta(this, this.operationFieldsWithoutSpecial); + }), + fields: computed('nonOperationFields', function() { + return expandAttributeMeta(this, this.nonOperationFields); }), }); diff --git a/ui/app/models/kmip/scope.js b/ui/app/models/kmip/scope.js index 107eb8d5c7..24171565bf 100644 --- a/ui/app/models/kmip/scope.js +++ b/ui/app/models/kmip/scope.js @@ -1,12 +1,19 @@ import { computed } from '@ember/object'; import DS from 'ember-data'; +import apiPath from 'vault/utils/api-path'; +import attachCapabilities from 'vault/lib/attach-capabilities'; const { attr } = DS; import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; -export default DS.Model.extend({ +let Model = DS.Model.extend({ name: attr('string'), + backend: attr({ readOnly: true }), attrs: computed(function() { return expandAttributeMeta(this, ['name']); }), }); + +export default attachCapabilities(Model, { + updatePath: apiPath`${'backend'}/scope/${'id'}`, +}); diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index eabd726277..517bb37748 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -16,11 +16,10 @@ export default DS.JSONSerializer.extend({ } let pk = this.get('primaryKey') || 'id'; let model = { [pk]: key }; - // if we've added a in the adapter, we want + // if we've added _requestQuery in the adapter, we want // attach it to the individual models if (payload._requestQuery) { model = { ...model, ...payload._requestQuery }; - delete payload._requestQuery; } return model; }); @@ -44,6 +43,7 @@ export default DS.JSONSerializer.extend({ normalizeResponse(store, primaryModelClass, payload, id, requestType) { const responseJSON = this.normalizeItems(payload, requestType); + delete payload._requestQuery; if (id && !responseJSON.id) { responseJSON.id = id; } diff --git a/ui/app/utils/api-path.js b/ui/app/utils/api-path.js index a5f845ec0f..145205e543 100644 --- a/ui/app/utils/api-path.js +++ b/ui/app/utils/api-path.js @@ -13,8 +13,8 @@ export default function apiPath(strings, ...keys) { let dict = data || {}; let result = [strings[0]]; assert( - `Expected ${keys.length} keys in apiPath context, only recieved ${Object.keys(data).length}`, - keys.length === Object.keys(data).length + `Expected ${keys.length} keys in apiPath context, only recieved ${Object.keys(data).join(',')}`, + Object.keys(data).length >= keys.length ); keys.forEach((key, i) => { result.push(dict[key], strings[i + 1]); diff --git a/ui/lib/core/addon/components/list-item.js b/ui/lib/core/addon/components/list-item.js index f89ccd1319..d54cd1858c 100644 --- a/ui/lib/core/addon/components/list-item.js +++ b/ui/lib/core/addon/components/list-item.js @@ -19,7 +19,7 @@ export default Component.extend({ successCallback(); } catch (e) { let errString = e.errors.join(' '); - flash.danger(failureMessage + errString); + flash.danger(failureMessage + ' ' + errString); model.rollbackAttributes(); } }), diff --git a/ui/lib/core/addon/components/menu-loader.js b/ui/lib/core/addon/components/menu-loader.js new file mode 100644 index 0000000000..0710d53693 --- /dev/null +++ b/ui/lib/core/addon/components/menu-loader.js @@ -0,0 +1,22 @@ +/** + * @module MenuLoader + * MenuLoader components are used to show a loading state when fetching data is triggered by opening a + * popup menu. + * + * @example + * ```js + * + * ``` + * + * @param loadingParam {Boolean} - If the value of this param is true, the loading state will be rendered, + * else the component will yield. + */ +import Component from '@ember/component'; +import layout from '../templates/components/menu-loader'; + +export default Component.extend({ + tagName: 'li', + classNames: 'action', + layout, + loadingParam: null, +}); diff --git a/ui/lib/core/addon/components/model-wrap.js b/ui/lib/core/addon/components/model-wrap.js new file mode 100644 index 0000000000..1cc1cdb798 --- /dev/null +++ b/ui/lib/core/addon/components/model-wrap.js @@ -0,0 +1,37 @@ +/** + * @module ModelWrap + * ModelWrap components provide a way to call methods on models directly from templates. This is done by yielding callMethod task to the wrapped component. + * + * @example + * ```js + * + +{{else}} + {{yield}} +{{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/model-wrap.hbs b/ui/lib/core/addon/templates/components/model-wrap.hbs new file mode 100644 index 0000000000..fe0ab81093 --- /dev/null +++ b/ui/lib/core/addon/templates/components/model-wrap.hbs @@ -0,0 +1 @@ +{{yield (hash callMethod=callMethod)}} diff --git a/ui/lib/core/app/components/menu-loader.js b/ui/lib/core/app/components/menu-loader.js new file mode 100644 index 0000000000..c357979e25 --- /dev/null +++ b/ui/lib/core/app/components/menu-loader.js @@ -0,0 +1 @@ +export { default } from 'core/components/menu-loader'; diff --git a/ui/lib/core/app/components/model-wrap.js b/ui/lib/core/app/components/model-wrap.js new file mode 100644 index 0000000000..6207ade266 --- /dev/null +++ b/ui/lib/core/app/components/model-wrap.js @@ -0,0 +1 @@ +export { default } from 'core/components/model-wrap'; diff --git a/ui/lib/kmip/addon/components/edit-form-kmip-role.js b/ui/lib/kmip/addon/components/edit-form-kmip-role.js index 47b529a951..cd322995a2 100644 --- a/ui/lib/kmip/addon/components/edit-form-kmip-role.js +++ b/ui/lib/kmip/addon/components/edit-form-kmip-role.js @@ -40,7 +40,7 @@ export default EditForm.extend({ model.set('operationAll', null); return resolve(model); } - model.newFields.without('role').forEach(field => { + model.operationFields.concat(['operationAll', 'operationNone']).forEach(field => { // this will set operationAll or operationNone to true if (field === display) { model.set(field, true); diff --git a/ui/lib/kmip/addon/routes/scope/roles/create.js b/ui/lib/kmip/addon/routes/scope/roles/create.js index cf12111c18..467dcf1523 100644 --- a/ui/lib/kmip/addon/routes/scope/roles/create.js +++ b/ui/lib/kmip/addon/routes/scope/roles/create.js @@ -9,6 +9,7 @@ export default Route.extend({ return this.paramsFor('scope').scope_name; }, beforeModel() { + this.store.unloadAll('kmip/role'); return this.pathHelp.getNewModel('kmip/role', this.secretMountPath.currentPath); }, model() { diff --git a/ui/lib/kmip/addon/routes/scopes/create.js b/ui/lib/kmip/addon/routes/scopes/create.js index 49e9576fe0..5f46aefa8f 100644 --- a/ui/lib/kmip/addon/routes/scopes/create.js +++ b/ui/lib/kmip/addon/routes/scopes/create.js @@ -4,6 +4,9 @@ import { inject as service } from '@ember/service'; export default Route.extend({ store: service(), secretMountPath: service(), + beforeModel() { + this.store.unloadAll('kmip/scope'); + }, model() { let model = this.store.createRecord('kmip/scope', { backend: this.secretMountPath.currentPath, diff --git a/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs b/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs index 69fa889ba6..e7c6673408 100644 --- a/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs +++ b/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs @@ -34,7 +34,7 @@ {{/each}} {{#if (eq this.display "choose")}}
- {{#each this.model.fields as |attr|}} + {{#each this.model.operationFormFields as |attr|}} {{/if}} + + {{#each this.model.fields as |attr|}} + + {{/each}}
diff --git a/ui/lib/kmip/addon/templates/credentials/index.hbs b/ui/lib/kmip/addon/templates/credentials/index.hbs index fa6d278aac..a3af166831 100644 --- a/ui/lib/kmip/addon/templates/credentials/index.hbs +++ b/ui/lib/kmip/addon/templates/credentials/index.hbs @@ -59,6 +59,29 @@ View credentials {{/link-to}} + {{#if list.item.deletePath.canDelete}} + + + Revoke credentials + + + {{/if}} {{else}} diff --git a/ui/lib/kmip/addon/templates/credentials/show.hbs b/ui/lib/kmip/addon/templates/credentials/show.hbs index a723cbb1ce..46a313d83b 100644 --- a/ui/lib/kmip/addon/templates/credentials/show.hbs +++ b/ui/lib/kmip/addon/templates/credentials/show.hbs @@ -15,6 +15,29 @@ > Back to role + {{#if model.deletePath.canDelete}} + + + Revoke credentials + + + {{/if}} - - Edit role - + {{#if model.updatePath.canUpdate}} + + + Delete role + + + {{/if}} + {{#if model.updatePath.canUpdate}} + + Edit role + + {{/if}}
diff --git a/ui/lib/kmip/addon/templates/scope/roles.hbs b/ui/lib/kmip/addon/templates/scope/roles.hbs index 70f2cdab0e..ed83f2bb4d 100644 --- a/ui/lib/kmip/addon/templates/scope/roles.hbs +++ b/ui/lib/kmip/addon/templates/scope/roles.hbs @@ -74,6 +74,35 @@ View role {{/link-to}} + {{#if list.item.updatePath.canUpdate}} + + {{#link-to "role.edit" this.scope list.item.id class="is-block"}} + Edit role + {{/link-to}} + + {{/if}} + {{#if list.item.updatePath.canDelete}} + + + Delete role + + + {{/if}} {{else}} diff --git a/ui/lib/kmip/addon/templates/scopes/index.hbs b/ui/lib/kmip/addon/templates/scopes/index.hbs index 03736fb5e8..21f49f768e 100644 --- a/ui/lib/kmip/addon/templates/scopes/index.hbs +++ b/ui/lib/kmip/addon/templates/scopes/index.hbs @@ -59,6 +59,28 @@ View scope {{/link-to}} + {{#if list.item.updatePath.canDelete}} + + + Delete scope + + + {{/if}} {{else}} diff --git a/ui/tests/integration/components/edit-form-kmip-role-test.js b/ui/tests/integration/components/edit-form-kmip-role-test.js index 8852d88487..051a2b17e2 100644 --- a/ui/tests/integration/components/edit-form-kmip-role-test.js +++ b/ui/tests/integration/components/edit-form-kmip-role-test.js @@ -8,6 +8,7 @@ import { render, settled, click } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; import engineResolverFor from 'ember-engines/test-support/engine-resolver-for'; +import { COMPUTEDS } from 'vault/models/kmip/role'; const resolver = engineResolverFor('kmip'); const flash = Service.extend({ @@ -16,12 +17,29 @@ const flash = Service.extend({ const namespace = Service.extend({}); const createModel = options => { - let model = EmberObject.extend({ - fields: computed('newFields', function() { - return this.newFields.map(field => ({ name: field, type: 'boolean' })); - }), + let model = EmberObject.extend(COMPUTEDS, { /* eslint-disable ember/avoid-leaking-state-in-ember-objects */ - newFields: ['operationAll', 'operationNone', 'operationGet', 'operationCreate', 'operationDestroy'], + newFields: [ + 'role', + 'operationActivate', + 'operationAddAttribute', + 'operationAll', + 'operationCreate', + 'operationDestroy', + 'operationDiscoverVersion', + 'operationGet', + 'operationGetAttributes', + 'operationLocate', + 'operationNone', + 'operationRekey', + 'operationRevoke', + 'tlsClientKeyBits', + 'tlsClientKeyType', + 'tlsClientTtl', + ], + fields: computed('operationFields', function() { + return this.operationFields.map(field => ({ name: field, type: 'boolean' })); + }), destroyRecord() { return resolve(); }, diff --git a/ui/tests/unit/adapters/kmip/role-test.js b/ui/tests/unit/adapters/kmip/role-test.js index d33214665a..8a8a65eb2b 100644 --- a/ui/tests/unit/adapters/kmip/role-test.js +++ b/ui/tests/unit/adapters/kmip/role-test.js @@ -6,25 +6,47 @@ module('Unit | Adapter | kmip/role', function(hooks) { let serializeTests = [ [ - 'operation_all is the only item present after serialization', + 'operation_all is the only operation item present after serialization', + { + serialize() { + return { operation_all: true, operation_get: true, operation_create: true, tls_ttl: '10s' }; + }, + record: { + nonOperationFields: ['tlsTtl'], + }, + }, + { + operation_all: true, + tls_ttl: '10s', + }, + ], + [ + 'serialize does not include nonOperationFields values if they are not set', { serialize() { return { operation_all: true, operation_get: true, operation_create: true }; }, + record: { + nonOperationFields: ['tlsTtl'], + }, }, { operation_all: true, }, ], [ - 'operation_none is the only item present after serialization', + 'operation_none is the only operation item present after serialization', { serialize() { - return { operation_none: true, operation_get: true, operation_add_attribute: true }; + return { operation_none: true, operation_get: true, operation_add_attribute: true, tls_ttl: '10s' }; + }, + record: { + nonOperationFields: ['tlsTtl'], }, }, { operation_none: true, + tls_ttl: '10s', }, ], [ @@ -39,6 +61,9 @@ module('Unit | Adapter | kmip/role', function(hooks) { operation_destroy: true, }; }, + record: { + nonOperationFields: ['tlsTtl'], + }, }, { operation_get: true, @@ -49,7 +74,6 @@ module('Unit | Adapter | kmip/role', function(hooks) { ]; for (let testCase of serializeTests) { let [name, snapshotStub, expected] = testCase; - test(`adapter serialize: ${name}`, function(assert) { let adapter = this.owner.lookup('adapter:kmip/role'); let result = adapter.serialize(snapshotStub); diff --git a/ui/tests/unit/utils/api-path-test.js b/ui/tests/unit/utils/api-path-test.js index c64673111d..169f9e4236 100644 --- a/ui/tests/unit/utils/api-path-test.js +++ b/ui/tests/unit/utils/api-path-test.js @@ -18,6 +18,6 @@ module('Unit | Util | api path', function() { let ret = apiPath`foo/${'one'}/${'two'}`; assert.throws(() => { ret({ one: 1 }); - }, /Error: Assertion Failed: Expected 2 keys in apiPath context, only recieved 1/); + }, /Error: Assertion Failed: Expected 2 keys in apiPath context, only recieved one/); }); });