Ui/transform roles list create (#9852)

* Can see list of roles, templates, and alphabets when you click on corresponding tab inside a transform secrets engine

* Cannot click on items in list other than transformations

* Can create a new transform role from the empty state or toolbar

* Creating a role redirects to the view of that role

* Breadcrumb links on transform roles work

* Role create form handles error
This commit is contained in:
Chelsea Shaw 2020-08-28 15:38:00 -05:00 committed by GitHub
parent 6478665b5e
commit d9ee6252bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 429 additions and 147 deletions

View File

@ -0,0 +1,7 @@
import BaseAdapter from './base';
export default BaseAdapter.extend({
pathForType() {
return 'alphabet';
},
});

View File

@ -0,0 +1,63 @@
import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapter.extend({
namespace: 'v1',
pathForType(type) {
return type.replace('transform/', '');
},
createOrUpdate(store, type, snapshot) {
const serializer = store.serializerFor(type.modelName);
const data = serializer.serialize(snapshot);
const { id } = snapshot;
let url = this.url(snapshot.record.get('backend'), type.modelName, id);
return this.ajax(url, 'POST', { data });
},
createRecord() {
return this.createOrUpdate(...arguments);
},
updateRecord() {
return this.createOrUpdate(...arguments, 'update');
},
deleteRecord(store, type, snapshot) {
const { id } = snapshot;
return this.ajax(this.url(snapshot.record.get('backend'), type.modelName, id), 'DELETE');
},
url(backend, modelType, id) {
let type = this.pathForType(modelType);
let url = `/${this.namespace}/${encodePath(backend)}/${encodePath(type)}`;
if (id) {
return `${url}/${encodePath(id)}`;
}
return url + '?list=true';
},
fetchByQuery(query) {
const { backend, modelName, id } = query;
return this.ajax(this.url(backend, modelName, id), 'GET').then(resp => {
return resp;
});
},
query(store, type, query) {
return this.fetchByQuery(query);
},
queryRecord(store, type, query) {
return this.ajax(this.url(query.backend, type.modelName, query.id), 'GET').then(result => {
return {
id: query.id,
...result,
};
});
},
// buildUrl(modelName, id, snapshot, requestType, query, returns) {},
});

View File

@ -1,25 +1,7 @@
import ApplicationAdapater from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapater.extend({
namespace: 'v1',
import BaseAdapter from './base';
export default BaseAdapter.extend({
pathForType() {
return 'role';
},
_url(backend, id) {
let type = this.pathForType();
let base = `/v1/${encodePath(backend)}/${type}`;
if (id) {
return `${base}/${encodePath(id)}`;
}
return base + '?list=true';
},
query(store, type, query) {
return this.ajax(this._url(query.backend), 'GET').then(result => {
return result;
});
},
});

View File

@ -1,25 +1,7 @@
import ApplicationAdapater from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default ApplicationAdapater.extend({
namespace: 'v1',
import BaseAdapter from './base';
export default BaseAdapter.extend({
pathForType() {
return 'template';
},
_url(backend, id) {
let type = this.pathForType();
let base = `${this.buildURL()}/${encodePath(backend)}/${type}`;
if (id) {
return `${base}/${encodePath(id)}`;
}
return base + '?list=true';
},
query(store, type, query) {
return this.ajax(this._url(query.backend), 'GET').then(result => {
return result;
});
},
});

View File

@ -0,0 +1,5 @@
import BaseAdapter from './base';
export default BaseAdapter.extend({
// custom stuff for transformation
});

View File

@ -47,6 +47,14 @@ export default Component.extend(FocusOnInsertMixin, {
this.get('router').transitionTo(...arguments);
},
modelPrefixFromType(modelType) {
let modelPrefix = '';
if (modelType && modelType.startsWith('transform/')) {
modelPrefix = `${modelType.replace('transform/', '')}/`;
}
return modelPrefix;
},
onEscape(e) {
if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') {
return;
@ -74,6 +82,7 @@ export default Component.extend(FocusOnInsertMixin, {
createOrUpdate(type, event) {
event.preventDefault();
const modelId = this.get('model.id') || this.get('model.name'); // transform comes in as model.name
const modelPrefix = this.modelPrefixFromType(this.get('model.constructor.modelName'));
// prevent from submitting if there's no key
// maybe do something fancier later
if (type === 'create' && isBlank(modelId)) {
@ -82,7 +91,7 @@ export default Component.extend(FocusOnInsertMixin, {
this.persist('save', () => {
this.hasDataChanges();
this.transitionToRoute(SHOW_ROUTE, modelId);
this.transitionToRoute(SHOW_ROUTE, `${modelPrefix}${modelId}`);
});
},

View File

@ -0,0 +1,3 @@
import TransformBase from './transform-edit-base';
export default TransformBase.extend({});

View File

@ -3,9 +3,10 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb';
export default Controller.extend(BackendCrumbMixin, {
backendController: controller('vault.cluster.secrets.backend'),
queryParams: ['initialKey'],
queryParams: ['initialKey', 'itemType'],
initialKey: '',
itemType: '',
actions: {
refresh: function() {

View File

@ -68,40 +68,38 @@ const SECRET_BACKENDS = {
create: 'Create transformation',
editComponent: 'transformation-edit',
},
// TODO: Add tabs as needed
// {
// name: 'roles',
// modelPrefix: 'role/',
// label: 'Roles',
// searchPlaceholder: 'Filter roles',
// item: 'roles',
// create: 'Create role',
// tab: 'role',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'transform-role-edit',
// },
// {
// name: 'templates',
// modelPrefix: 'template/',
// label: 'Templates',
// searchPlaceholder: 'Filter templates',
// item: 'templates',
// create: 'Create template',
// tab: 'template',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'transform-template-edit',
// },
// {
// name: 'alphabets',
// modelPrefix: 'alphabet/',
// label: 'Alphabets',
// searchPlaceholder: 'Filter alphabets',
// item: 'alphabets',
// create: 'Create alphabet',
// tab: 'alphabet',
// listItemPartial: 'partials/secret-list/item',
// editComponent: 'alphabet-edit',
// },
{
name: 'role',
modelPrefix: 'role/',
label: 'Roles',
searchPlaceholder: 'Filter roles',
item: 'role',
create: 'Create role',
tab: 'role',
editComponent: 'transform-role-edit',
},
{
name: 'templates',
modelPrefix: 'template/',
label: 'Templates',
searchPlaceholder: 'Filter templates',
item: 'templates',
create: 'Create template',
tab: 'templates',
editComponent: 'transform-template-edit',
hideCreate: true,
},
{
name: 'alphabets',
modelPrefix: 'alphabet/',
label: 'Alphabets',
searchPlaceholder: 'Filter alphabets',
item: 'alphabets',
create: 'Create alphabet',
tab: 'alphabets',
editComponent: 'alphabet-edit',
hideCreate: true,
},
],
},
transit: {

View File

@ -92,5 +92,6 @@ const Model = DS.Model.extend({
});
export default attachCapabilities(Model, {
// TODO: Update to dynamic backend name
updatePath: apiPath`transform/transformation/${'id'}`,
});

View File

@ -0,0 +1,5 @@
import DS from 'ember-data';
export default DS.Model.extend({
templates: DS.hasMany('template'),
});

View File

@ -1,3 +1,42 @@
import { computed } from '@ember/object';
import DS from 'ember-data';
import { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import attachCapabilities from 'vault/lib/attach-capabilities';
export default DS.Model.extend({});
const { attr } = DS;
const Model = DS.Model.extend({
// used for getting appropriate options for backend
idPrefix: 'role/',
// the id prefixed with `role/` so we can use it as the *secret param for the secret show route
idForNav: computed('id', 'idPrefix', function() {
let modelId = this.id || '';
return `${this.idPrefix}${modelId}`;
}),
name: attr('string', {
// TODO: make this required for making a transformation
label: 'Name',
fieldValue: 'id',
readOnly: true,
subText: 'The name for your role. This cannot be edited later.',
}),
transformations: attr('string', {
editType: 'searchSelect',
fallbackComponent: 'string-list',
label: 'Transformations',
models: ['transform'],
subLabel: 'Transformations',
subText: 'Select which transformations this role will have access to. It must already exist.',
}),
attrs: computed('transformations', function() {
let keys = ['name', 'transformations'];
return expandAttributeMeta(this, keys);
}),
});
export default attachCapabilities(Model, {
updatePath: apiPath`${'backend'}/role/${'id'}`,
});

View File

@ -1,3 +1,7 @@
import DS from 'ember-data';
export default DS.Model.extend({});
export default DS.Model.extend({
name: DS.attr('string'),
alphabet: DS.belongsTo('transform/alphabet'),
transformations: DS.hasMany('transformation'),
});

View File

@ -0,0 +1,7 @@
import DS from 'ember-data';
export default DS.Model.extend({
name: DS.attr('string'),
template: DS.belongsTo('transform/template'),
roles: DS.belongsTo('transform/role'),
});

View File

@ -20,14 +20,24 @@ let secretModel = (store, backend, key) => {
return secret;
};
const transformModel = queryParams => {
let modelType = 'transform';
if (!queryParams || !queryParams.itemType) return modelType;
return `${modelType}/${queryParams.itemType}`;
};
export default EditBase.extend({
wizard: service(),
createModel(transition) {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
const modelType = this.modelType(backend);
let modelType = this.modelType(backend);
if (modelType === 'role-ssh') {
return this.store.createRecord(modelType, { keyType: 'ca' });
}
if (modelType === 'transform') {
modelType = transformModel(transition.queryParams);
}
if (modelType !== 'secret' && modelType !== 'secret-v2') {
if (this.get('wizard.featureState') === 'details' && this.get('wizard.componentState') === 'transit') {
this.get('wizard').transitionFeatureMachine('details', 'CONTINUE', 'transit');

View File

@ -22,6 +22,25 @@ export default Route.extend({
},
},
modelTypeForTransform(tab) {
let modelType;
switch (tab) {
case 'role':
modelType = 'transform/role';
break;
case 'templates':
modelType = 'transform/template';
break;
case 'alphabets':
modelType = 'transform/alphabet';
break;
default:
modelType = 'transform'; // CBS TODO: transform/transformation
break;
}
return modelType;
},
secretParam() {
let { secret } = this.paramsFor(this.routeName);
return secret ? normalizePath(secret) : '';
@ -56,7 +75,7 @@ export default Route.extend({
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
transform: 'transform',
transform: this.modelTypeForTransform(tab),
aws: 'role-aws',
pki: tab === 'certs' ? 'pki-certificate' : 'role-pki',
// secret or secret-v2

View File

@ -71,7 +71,7 @@ export default Route.extend(UnloadModelRoute, {
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
transform: 'transform',
transform: secret && secret.startsWith('role/') ? 'transform/role' : 'transform', // CBS TODO: switch out better
aws: 'role-aws',
pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki',
cubbyhole: 'secret',
@ -192,13 +192,16 @@ export default Route.extend(UnloadModelRoute, {
let secret = this.secretParam();
let backend = this.enginePathParam();
let modelType = this.modelType(backend, secret);
if (!secret) {
secret = '\u0020';
}
if (modelType === 'pki-certificate') {
secret = secret.replace('cert/', '');
}
if (modelType.startsWith('transform/')) {
// CBS TODO: we'll have more things to replace than just role/
secret = secret.replace('role/', '');
}
let secretModel;
let capabilities = this.capabilities(secret);

View File

@ -0,0 +1,101 @@
<PageHeader as |p|>
<p.top>
{{key-value-header
baseKey=(hash display=model.id id=model.idForNav)
path="vault.cluster.secrets.backend.list"
mode=mode
root=root
showCurrent=true
}}
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-secret-header="true">
{{#if (eq mode "create") }}
Create Role
{{else if (eq mode "edit")}}
Edit Role
{{else}}
Role <code>{{model.id}}</code>
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
{{#if (eq mode "show")}}
<Toolbar>
<ToolbarActions>
{{!-- TODO: Ability to delete and edit role
{{#if (or capabilities.canUpdate capabilities.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if capabilities.canDelete}}
<a
class="toolbar-link"
onclick={{action "delete"}}
data-test-transformation-role-delete
>
Delete role
</a>
{{/if}}
{{#if capabilities.canUpdate }}
<ToolbarSecretLink
@secret={{model.id}}
@mode="edit"
@data-test-edit-link=true
@replace=true
>
Edit role
</ToolbarSecretLink>
{{/if}} --}}
</ToolbarActions>
</Toolbar>
{{/if}}
{{#if (or (eq mode 'edit') (eq mode 'create'))}}
<form onsubmit={{action "createOrUpdate" mode}}>
<div class="box is-sideless is-fullwidth is-marginless">
{{message-error model=model}}
<NamespaceReminder @mode={{mode}} @noun="Transform role" />
{{#each model.attrs as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{buttonDisabled}}
class="button is-primary"
data-test-role-ssh-create=true
>
{{#if (eq mode 'create')}}
Create transformation
{{else if (eq mode 'edit')}}
Save
{{/if}}
</button>
{{#secret-link
mode=(if (eq mode "create") "list" "show")
class="button"
secret=model.id
}}
Cancel
{{/secret-link}}
</div>
</div>
</form>
{{else}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}}
{{else}}
{{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}}
{{/if}}
{{/each}}
</div>
{{/if}}

View File

@ -1,60 +1,76 @@
{{!-- TODO do not let click if !canRead --}}
{{#linked-block
"vault.cluster.secrets.backend.show"
item.id
class="list-item-row"
data-test-secret-link=item.id
encode=true
queryParams=(secret-query-params backendModel.type)
}}
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode="show"
@secret={{item.id}}
@queryParams={{if (eq backendModel.type "transform") (query-params tab="actions") ""}}
@class="has-text-black has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</SecretLink>
</div>
<div class="column has-text-right">
<PopupMenu name="secret-menu">
<nav class="menu">
<ul class="menu-list">
{{#if (or item.versionPath.isLoading item.secretPath.isLoading)}}
<li class="action">
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
{{#if item.updatePath.canRead}}
{{!-- CBS TODO: make this generic to any transform model type? --}}
{{#if (eq options.item "transformation")}}
{{#linked-block
"vault.cluster.secrets.backend.show"
item.id
class="list-item-row"
data-test-secret-link=item.id
encode=true
queryParams=(secret-query-params backendModel.type)
}}
<div class="columns is-mobile">
<div class="column is-10">
<SecretLink
@mode="show"
@secret={{item.id}}
@queryParams={{if (eq backendModel.type "transform") (query-params tab="actions") ""}}
@class="has-text-black has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</SecretLink>
</div>
<div class="column has-text-right">
{{#if (or item.updatePath.canRead item.updatePath.canUpdate)}}
<PopupMenu name="secret-menu">
<nav class="menu">
<ul class="menu-list">
{{#if (or item.versionPath.isLoading item.secretPath.isLoading)}}
<li class="action">
<SecretLink
@mode="show"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Details
</SecretLink>
<button disabled type="button" class="link button is-loading is-transparent">
loading
</button>
</li>
{{else}}
{{#if item.updatePath.canRead}}
<li class="action">
<SecretLink
@mode="show"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Details
</SecretLink>
</li>
{{/if}}
{{#if item.updatePath.canUpdate}}
<li class="action">
<SecretLink
@mode="edit"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Edit
</SecretLink>
</li>
{{/if}}
{{/if}}
{{#if item.updatePath.canUpdate}}
<li class="action">
<SecretLink
@mode="edit"
@secret={{item.id}}
@class="has-text-black has-text-weight-semibold">
Edit
</SecretLink>
</li>
{{/if}}
{{/if}}
</ul>
</nav>
</PopupMenu>
</ul>
</nav>
</PopupMenu>
{{/if}}
</div>
</div>
</div>
{{/linked-block}}
{{/linked-block}}
{{else}}
<div class="list-item-row">
<div class="columns is-mobile">
<div class="column is-12 has-text-grey has-text-weight-semibold">
<Icon
@glyph='file-outline'
@class="has-text-grey-light"/>
{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}}
</div>
</div>
</div>
{{/if}}

View File

@ -42,15 +42,17 @@
{{/if}}
<ToolbarActions>
<ToolbarSecretLink
@secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=(or filter baseKey.id)}}
@data-test-secret-create=true
>
{{options.create}}
</ToolbarSecretLink>
{{#unless options.hideCreate}}
<ToolbarSecretLink
@secret=''
@mode="create"
@type="add"
@queryParams={{query-params initialKey=(or filter baseKey.id) itemType=tab}}
@data-test-secret-create=true
>
{{options.create}}
</ToolbarSecretLink>
{{/unless}}
</ToolbarActions>
</Toolbar>
{{/if}}
@ -84,7 +86,7 @@
{{#secret-link
mode="create"
secret=''
queryParams=(query-params initialKey=(or filter baseKey.id))
queryParams=(query-params initialKey=(or filter baseKey.id) itemType=tab)
class="link"
}}
{{options.create}}

View File

@ -0,0 +1,25 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | transform-role-edit', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// TODO: Fill out these tests, merging without to unblock other work
await render(hbs`{{transform-role-edit}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#transform-role-edit}}
template block text
{{/transform-role-edit}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});