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
This commit is contained in:
Matthew Irish 2019-07-02 16:23:07 -05:00 committed by GitHub
parent 88cb465184
commit 5e002bec87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 360 additions and 36 deletions

View File

@ -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,
},
});
},
});

View File

@ -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;

View File

@ -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');
},
});

View File

@ -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`,
});

View File

@ -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);
}),
});

View File

@ -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'}`,
});

View File

@ -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;
}

View File

@ -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]);

View File

@ -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();
}
}),

View File

@ -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
* <MenuLoader @loadingParam={model.updatePath.isPending} />
* ```
*
* @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,
});

View File

@ -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
* <ModelWrap as |m|>
<button onclick={{action (perform m.callMethod "save" model "Saved!" "Errored!" (transition-to "route")}}>
* </ModelWrap>
* ```
*
* @yields callMethod {Function}
*
*/
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { task } from 'ember-concurrency';
import layout from '../templates/components/model-wrap';
export default Component.extend({
layout,
flashMessages: service(),
tagName: '',
callMethod: task(function*(method, model, successMessage, failureMessage, successCallback = () => {}) {
let flash = this.get('flashMessages');
try {
yield model[method]();
flash.success(successMessage);
successCallback();
} catch (e) {
let errString = e.errors.join(' ');
flash.danger(failureMessage + ' ' + errString);
model.rollbackAttributes();
}
}),
});

View File

@ -48,5 +48,9 @@ export default Mixin.create({
setFilterFocus(bool) {
this.set('filterFocused', bool);
},
refresh() {
// bubble to the list-route
this.send('reload');
},
},
});

View File

@ -0,0 +1,7 @@
{{#if @loadingParam}}
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
{{else}}
{{yield}}
{{/if}}

View File

@ -0,0 +1 @@
{{yield (hash callMethod=callMethod)}}

View File

@ -0,0 +1 @@
export { default } from 'core/components/menu-loader';

View File

@ -0,0 +1 @@
export { default } from 'core/components/model-wrap';

View File

@ -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);

View File

@ -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() {

View File

@ -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,

View File

@ -34,7 +34,7 @@
{{/each}}
{{#if (eq this.display "choose")}}
<div class="box is-sideless is-shadowless is-marginless">
{{#each this.model.fields as |attr|}}
{{#each this.model.operationFormFields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@ -43,6 +43,14 @@
{{/each}}
</div>
{{/if}}
{{#each this.model.fields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">

View File

@ -59,6 +59,29 @@
View credentials
{{/link-to}}
</li>
{{#if list.item.deletePath.canDelete}}
<MenuLoader @loadingParam={{list.item.deletePath.isPending}}>
<ConfirmAction
@buttonClasses="link is-destroy"
@onConfirmAction={{action
(perform
Item.callMethod
"destroyRecord"
list.item
"Successfully revoked credentials"
"There was an error revoking the credentials"
(action "refresh")
)
}}
@confirmTitle="Revoke this?"
@confirmMessage="Any client using these credentials will no longer be able to."
@cancelButtonText="Cancel"
@confirmButtonText="Revoke"
>
Revoke credentials
</ConfirmAction>
</MenuLoader>
{{/if}}
</Item.menu>
</ListItem>
{{else}}

View File

@ -15,6 +15,29 @@
>
Back to role
</ToolbarLink>
{{#if model.deletePath.canDelete}}
<ModelWrap as |m|>
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action
(perform
m.callMethod
"destroyRecord"
model
"Successfully revoked credentials"
"There was an error revoking credentials"
(transition-to "vault.cluster.secrets.backend.kmip.credentials.index" this.scope this.role)
)
}}
@confirmTitle="Revoke this?"
@confirmMessage="Any client using these credentials will no longer be able to."
@cancelButtonText="Cancel"
@confirmButtonText="Revoke"
>
Revoke credentials
</ConfirmAction>
</ModelWrap>
{{/if}}
<CopyButton
class="toolbar-link"
@clipboardText={{model.certificate}}

View File

@ -1,11 +1,34 @@
<HeaderCredentials @role={{this.role}} @scope={{this.scope}} />
<Toolbar>
<ToolbarActions>
<ToolbarLink
@params={{array "role.edit" this.scope this.role}}
>
Edit role
</ToolbarLink>
{{#if model.updatePath.canUpdate}}
<ModelWrap as |m|>
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action
(perform
m.callMethod
"destroyRecord"
model
(concat "Successfully deleted role " model.id)
(concat "There was an error deleting the role " model.id)
(transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.scope)
)
}}
@confirmMessage={{concat "Are you sure you want to delete " model.id "?"}}
@cancelButtonText="Cancel"
>
Delete role
</ConfirmAction>
</ModelWrap>
{{/if}}
{{#if model.updatePath.canUpdate}}
<ToolbarLink
@params={{array "role.edit" this.scope this.role}}
>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-shadowless">

View File

@ -74,6 +74,35 @@
View role
{{/link-to}}
</li>
{{#if list.item.updatePath.canUpdate}}
<MenuLoader @loadingParam={{list.item.updatePath.isPending}}>
{{#link-to "role.edit" this.scope list.item.id class="is-block"}}
Edit role
{{/link-to}}
</MenuLoader>
{{/if}}
{{#if list.item.updatePath.canDelete}}
<MenuLoader @loadingParam={{list.item.updatePath.isPending}}>
<ConfirmAction
@buttonClasses="link is-destroy"
@onConfirmAction={{action
(perform
Item.callMethod
"destroyRecord"
list.item
(concat "Successfully deleted role " list.item.id)
(concat "There was an error deleting the role " list.item.id)
(action "refresh")
)
}}
@confirmMessage={{concat "Are you sure you want to delete " list.item.id "?"}}
@cancelButtonText="Cancel"
data-test-scope-delete="true"
>
Delete role
</ConfirmAction>
</MenuLoader>
{{/if}}
</Item.menu>
</ListItem>
{{else}}

View File

@ -59,6 +59,28 @@
View scope
{{/link-to}}
</li>
{{#if list.item.updatePath.canDelete}}
<MenuLoader @loadingParam={{list.item.updatePath.isPending}}>
<ConfirmAction
@buttonClasses="link is-destroy"
@onConfirmAction={{action
(perform
Item.callMethod
"destroyRecord"
list.item
(concat "Successfully deleted scope " list.item.id)
(concat "There was an error deleting the scope " list.item.id)
(action "refresh")
)
}}
@confirmMessage={{concat "Are you sure you want to delete " list.item.id "?"}}
@cancelButtonText="Cancel"
data-test-scope-delete="true"
>
Delete scope
</ConfirmAction>
</MenuLoader>
{{/if}}
</Item.menu>
</ListItem>
{{else}}

View File

@ -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();
},

View File

@ -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);

View File

@ -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/);
});
});