From c013e4a7412a992f8b14ef81a82efcaa6517ab70 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Tue, 31 Aug 2021 09:41:41 -0600 Subject: [PATCH] UI add custom metadata to KV2 (#12169) * initial setup * form field editType kv is very helpful * setting up things * setup two routes for metadata * routing * clean up routing * meh router changes not my favorite but its working * show metadata * add controller for backendCrumb mixin * setting up edit metadata and trimming SecretEditMetadata component * add edit metadata save functionality * create new version work * setup model and formfieldgroups for added config data. * add config network request to secret-engine * fix validations on config * add config rows * breaking up secret edit * add validation for metadata on create * stuff, but broken now on metadata tab * fix metadata route error * permissions * saving small text changes * permissions * cleanup * some test fixes and convert secret create or update to glimmer * all these changes fix secret create kv test * remove alert banners per design request * fix error for array instead of object in jsonEditor * add changelog * styling * turn into glimmer component * cleanup * test failure fix * add delete or * clean up * remove all hardcoded for api integration * add helper and fix create mode on create new version * address chelseas pr comments * add jsdocs to helper * fix test --- changelog/12169.txt | 3 + ui/app/adapters/secret-engine.js | 43 ++- ui/app/components/kv-object-editor.js | 35 +- ui/app/components/mount-backend-form.js | 23 +- ui/app/components/secret-create-or-update.js | 264 +++++++++++++++ ui/app/components/secret-delete-menu.js | 52 +-- ui/app/components/secret-edit-metadata.js | 79 +++++ ui/app/components/secret-edit-toolbar.js | 108 ++++++ ui/app/components/secret-edit.js | 317 ++---------------- .../vault/cluster/secrets/backend/metadata.js | 10 + ui/app/helpers/split-object.js | 32 ++ ui/app/models/secret-engine.js | 40 ++- ui/app/models/secret-v2.js | 37 +- ui/app/router.js | 4 +- .../cluster/secrets/backend/configuration.js | 35 ++ .../cluster/secrets/backend/create-root.js | 7 +- .../cluster/secrets/backend/edit-metadata.js | 3 + .../vault/cluster/secrets/backend/metadata.js | 24 ++ ui/app/styles/components/form-section.scss | 4 + ui/app/styles/core/box.scss | 17 +- ui/app/styles/core/forms.scss | 4 + .../templates/components/kv-object-editor.hbs | 34 +- .../components/secret-create-or-update.hbs | 236 +++++++++++++ .../components/secret-delete-menu.hbs | 16 +- .../components/secret-edit-display.hbs | 115 ------- .../components/secret-edit-metadata.hbs | 56 ++++ .../components/secret-edit-toolbar.hbs | 118 +++++++ ui/app/templates/components/secret-edit.hbs | 289 +++------------- .../templates/components/secret-list/item.hbs | 6 +- .../cluster/secrets/backend/edit-metadata.hbs | 21 ++ .../cluster/secrets/backend/metadata.hbs | 73 ++++ .../addon/templates/components/form-field.hbs | 35 +- .../secrets/backend/kv/secret-test.js | 21 +- .../components/secret-edit-test.js | 1 + .../pages/secrets/backend/kv/edit-secret.js | 5 +- 35 files changed, 1430 insertions(+), 737 deletions(-) create mode 100644 changelog/12169.txt create mode 100644 ui/app/components/secret-create-or-update.js create mode 100644 ui/app/components/secret-edit-metadata.js create mode 100644 ui/app/components/secret-edit-toolbar.js create mode 100644 ui/app/controllers/vault/cluster/secrets/backend/metadata.js create mode 100644 ui/app/helpers/split-object.js create mode 100644 ui/app/routes/vault/cluster/secrets/backend/edit-metadata.js create mode 100644 ui/app/routes/vault/cluster/secrets/backend/metadata.js create mode 100644 ui/app/templates/components/secret-create-or-update.hbs delete mode 100644 ui/app/templates/components/secret-edit-display.hbs create mode 100644 ui/app/templates/components/secret-edit-metadata.hbs create mode 100644 ui/app/templates/components/secret-edit-toolbar.hbs create mode 100644 ui/app/templates/vault/cluster/secrets/backend/edit-metadata.hbs create mode 100644 ui/app/templates/vault/cluster/secrets/backend/metadata.hbs diff --git a/changelog/12169.txt b/changelog/12169.txt new file mode 100644 index 000000000..6842cbd1d --- /dev/null +++ b/changelog/12169.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Add custom metadata to KV secret engine and metadata to config +``` diff --git a/ui/app/adapters/secret-engine.js b/ui/app/adapters/secret-engine.js index 21f926a45..c2bd214f0 100644 --- a/ui/app/adapters/secret-engine.js +++ b/ui/app/adapters/secret-engine.js @@ -1,6 +1,7 @@ import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { splitObject } from 'vault/helpers/split-object'; export default ApplicationAdapter.extend({ url(path) { @@ -8,6 +9,10 @@ export default ApplicationAdapter.extend({ return path ? url + '/' + encodePath(path) : url; }, + urlForConfig(path) { + return `/v1/${path}/config`; + }, + internalURL(path) { let url = `/${this.urlPrefix()}/internal/ui/mounts`; if (path) { @@ -26,15 +31,37 @@ export default ApplicationAdapter.extend({ createRecord(store, type, snapshot) { const serializer = store.serializerFor(type.modelName); - const data = serializer.serialize(snapshot); + let data = serializer.serialize(snapshot); const path = snapshot.attr('path'); - - return this.ajax(this.url(path), 'POST', { data }).then(() => { - // ember data doesn't like 204s if it's not a DELETE - return { - data: assign({}, data, { path: path + '/', id: path }), - }; - }); + // for kv2 we make two network requests + if (data.type === 'kv' && data.options.version !== 1) { + // data has both data for sys mount and the config, we need to separate them + let splitObjects = splitObject(data, ['max_versions', 'delete_version_after', 'cas_required']); + let configData; + [configData, data] = splitObjects; + // first create the engine + return this.ajax(this.url(path), 'POST', { data }) + .then(() => { + // second modify config on engine + return this.ajax(this.urlForConfig(path), 'POST', { data: configData }); + }) + .then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: assign({}, data, { path: path + '/', id: path }), + }; + }) + .catch(e => { + console.log(e, 'error'); + }); + } else { + return this.ajax(this.url(path), 'POST', { data }).then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: assign({}, data, { path: path + '/', id: path }), + }; + }); + } }, findRecord(store, type, path, snapshot) { diff --git a/ui/app/components/kv-object-editor.js b/ui/app/components/kv-object-editor.js index a8da4b35c..46e45d204 100644 --- a/ui/app/components/kv-object-editor.js +++ b/ui/app/components/kv-object-editor.js @@ -1,3 +1,26 @@ +/** + * @module KvObjectEditor + * KvObjectEditor components are called in FormFields when the editType on the model is kv. They are used to show a key-value input field. + * + * @example + * ```js + * + * ``` + * @param {string} value - the value is captured from the model. + * @param {function} onChange - function that captures the value on change + * @param {function} onKeyUp - function passed in that handles the dom keyup event. Used for validation on the kv custom metadata. + * @param {string} [label] - label displayed over key value inputs + * @param {string} [warning] - warning that is displayed + * @param {string} [helpText] - helper text. In tooltip. + * @param {string} [subText] - placed under label. + * @param {boolean} [small-label]- change label size. + * @param {boolean} [formSection] - if false the component is meant to live outside of a form, like in the customMetadata which is nested already inside a form-section. + */ + import { isNone } from '@ember/utils'; import { assert } from '@ember/debug'; import Component from '@ember/component'; @@ -7,12 +30,15 @@ import KVObject from 'vault/lib/kv-object'; export default Component.extend({ 'data-test-component': 'kv-object-editor', - classNames: ['field', 'form-section'], + classNames: ['field'], + classNameBindings: ['formSection:form-section'], + formSection: true, // public API // Ember Object to mutate value: null, label: null, helpText: null, + subText: null, // onChange will be called with the changed Value onChange() {}, @@ -65,5 +91,12 @@ export default Component.extend({ data.removeAt(index); this.onChange(data.toJSON()); }, + + handleKeyUp(name, value) { + if (!this.onKeyUp) { + return; + } + this.onKeyUp(name, value); + }, }, }); diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index 5280ec75d..0bd3f446e 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -108,11 +108,24 @@ export default Component.extend({ actions: { onKeyUp(name, value) { - this.mountModel.set('path', value); - this.mountModel.validations.attrs.path.isValid - ? set(this.validationMessages, 'path', '') - : set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message); - + // validate path + if (name === 'path') { + this.mountModel.set('path', value); + this.mountModel.validations.attrs.path.isValid + ? set(this.validationMessages, 'path', '') + : set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message); + } + // check maxVersions is a number + if (name === 'maxVersions') { + this.mountModel.set('maxVersions', value); + this.mountModel.validations.attrs.maxVersions.isValid + ? set(this.validationMessages, 'maxVersions', '') + : set( + this.validationMessages, + 'maxVersions', + this.mountModel.validations.attrs.maxVersions.message + ); + } this.mountModel.validate().then(({ validations }) => { this.set('isFormInvalid', !validations.isValid); }); diff --git a/ui/app/components/secret-create-or-update.js b/ui/app/components/secret-create-or-update.js new file mode 100644 index 000000000..f58e9605e --- /dev/null +++ b/ui/app/components/secret-create-or-update.js @@ -0,0 +1,264 @@ +/** + * @module SecretCreateOrUpdate + * SecretCreateOrUpdate component displays either the form for creating a new secret or creating a new version of the secret + * + * @example + * ```js + * + * ``` + * @param {string} mode - create, edit, show determines what view to display + * @param {object} model - the route model, comes from secret-v2 ember record + * @param {boolean} showAdvancedMode - whether or not to show the JSON editor + * @param {object} modelForData - a class that helps track secret data, defined in secret-edit + * @param {boolean} isV2 - whether or not KV1 or KV2 + * @param {object} secretData - class that is created in secret-edit + * @param {boolean} canCreateSecretMetadata - based on permissions to the /metadata/ endpoint. If user has secret create access. + */ + +import Component from '@glimmer/component'; +import ControlGroupError from 'vault/lib/control-group-error'; +import Ember from 'ember'; +import keys from 'vault/lib/keycodes'; + +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { set } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +import { isBlank, isNone } from '@ember/utils'; +import { task, waitForEvent } from 'ember-concurrency'; + +const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default class SecretCreateOrUpdate extends Component { + @tracked codemirrorString = null; + @tracked error = null; + @tracked secretPaths = null; + @tracked validationErrorCount = 0; + @tracked validationMessages = null; + + @service controlGroup; + @service router; + @service store; + @service wizard; + + constructor() { + super(...arguments); + this.codemirrorString = this.args.secretData.toJSONString(); + this.validationMessages = { + path: '', + }; + // for validation, return array of path names already assigned + if (Ember.testing) { + this.secretPaths = ['beep', 'bop', 'boop']; + } else { + let adapter = this.store.adapterFor('secret-v2'); + let type = { modelName: 'secret-v2' }; + let query = { backend: this.args.model.backend }; + adapter.query(this.store, type, query).then(result => { + this.secretPaths = result.data.keys; + }); + } + this.checkRows(); + + if (this.args.mode === 'edit') { + this.addRow(); + } + } + checkRows() { + if (this.args.secretData.length === 0) { + this.addRow(); + } + } + checkValidation(name, value) { + if (name === 'path') { + !value + ? set(this.validationMessages, name, `${name} can't be blank.`) + : set(this.validationMessages, name, ''); + } + // check duplicate on path + if (name === 'path' && value) { + this.secretPaths?.includes(value) + ? set(this.validationMessages, name, `A secret with this ${name} already exists.`) + : set(this.validationMessages, name, ''); + } + let values = Object.values(this.validationMessages); + this.validationErrorCount = values.filter(Boolean).length; + } + onEscape(e) { + if (e.keyCode !== keys.ESC || this.args.mode !== 'show') { + return; + } + const parentKey = this.args.model.parentKey; + if (parentKey) { + this.transitionToRoute(LIST_ROUTE, parentKey); + } else { + this.transitionToRoute(LIST_ROOT_ROUTE); + } + } + // successCallback is called in the context of the component + persistKey(successCallback) { + let secret = this.args.model; + let secretData = this.args.modelForData; + let isV2 = this.args.isV2; + let key = secretData.get('path') || secret.id; + + if (key.startsWith('/')) { + key = key.replace(/^\/+/g, ''); + secretData.set(secretData.pathAttr, key); + } + + return secretData + .save() + .then(() => { + if (!secretData.isError) { + if (isV2) { + secret.set('id', key); + } + if (isV2 && Object.keys(secret.changedAttributes()).length > 0) { + // save secret metadata + secret + .save() + .then(() => { + this.saveComplete(successCallback, key); + }) + .catch(e => { + // when mode is not create the metadata error is handled in secret-edit-metadata + if (this.mode === 'create') { + this.error = e.errors.join(' '); + } + return; + }); + } else { + this.saveComplete(successCallback, key); + } + } + }) + .catch(error => { + if (error instanceof ControlGroupError) { + let errorMessage = this.controlGroup.logFromError(error); + this.error = errorMessage.content; + } + throw error; + }); + } + saveComplete(callback, key) { + if (this.wizard.featureState === 'secret') { + this.wizard.transitionFeatureMachine('secret', 'CONTINUE'); + } + callback(key); + } + transitionToRoute() { + return this.router.transitionTo(...arguments); + } + + get isCreateNewVersionFromOldVersion() { + let model = this.args.model; + if (!model) { + return false; + } + if ( + !model.failedServerRead && + !model.selectedVersion?.failedServerRead && + model.selectedVersion?.version !== model.currentVersion + ) { + return true; + } + return false; + } + + @(task(function*(name, value) { + this.checkValidation(name, value); + while (true) { + let event = yield waitForEvent(document.body, 'keyup'); + this.onEscape(event); + } + }) + .on('didInsertElement') + .cancelOn('willDestroyElement')) + waitForKeyUp; + + @action + addRow() { + const data = this.args.secretData; + // fired off on init + if (isNone(data.findBy('name', ''))) { + data.pushObject({ name: '', value: '' }); + this.handleChange(); + } + this.checkRows(); + } + @action + codemirrorUpdated(val, codemirror) { + this.error = null; + codemirror.performLint(); + const noErrors = codemirror.state.lint.marked.length === 0; + if (noErrors) { + try { + this.args.secretData.fromJSONString(val); + set(this.args.modelForData, 'secretData', this.args.secretData.toJSON()); + } catch (e) { + this.error = e.message; + } + } + this.codemirrorString = val; + } + @action + createOrUpdateKey(type, event) { + event.preventDefault(); + if (type === 'create' && isBlank(this.args.modelForData.path || this.args.modelForData.id)) { + this.checkValidation('path', ''); + return; + } + + this.persistKey(() => { + this.transitionToRoute(SHOW_ROUTE, this.args.model.path || this.args.model.id); + }); + } + @action + deleteRow(name) { + const data = this.args.secretData; + const item = data.findBy('name', name); + if (isBlank(item.name)) { + return; + } + data.removeObject(item); + this.checkRows(); + this.handleChange(); + } + @action + formatJSON() { + this.codemirrorString = this.args.secretData.toJSONString(true); + } + @action + handleChange() { + this.codemirrorString = this.args.secretData.toJSONString(true); + set(this.args.modelForData, 'secretData', this.args.secretData.toJSON()); + } + //submit on shift + enter + @action + handleKeyDown(e) { + e.stopPropagation(); + if (!(e.keyCode === keys.ENTER && e.metaKey)) { + return; + } + let $form = this.element.querySelector('form'); + if ($form.length) { + $form.submit(); + } + } + @action + updateValidationErrorCount(errorCount) { + this.validationErrorCount = errorCount; + } +} diff --git a/ui/app/components/secret-delete-menu.js b/ui/app/components/secret-delete-menu.js index e5feb32e7..e20b48280 100644 --- a/ui/app/components/secret-delete-menu.js +++ b/ui/app/components/secret-delete-menu.js @@ -17,30 +17,6 @@ export default class SecretDeleteMenu extends Component { @tracked showDeleteModal = false; - @maybeQueryRecord( - 'capabilities', - context => { - if (!context.args.model) { - return; - } - let backend = context.args.model.backend; - let id = context.args.model.id; - let path = context.args.isV2 - ? `${encodeURIComponent(backend)}/data/${encodeURIComponent(id)}` - : `${encodeURIComponent(backend)}/${encodeURIComponent(id)}`; - return { - id: path, - }; - }, - 'isV2', - 'model', - 'model.id', - 'mode' - ) - updatePath; - @alias('updatePath.canDelete') canDelete; - @alias('updatePath.canUpdate') canUpdate; - @maybeQueryRecord( 'capabilities', context => { @@ -100,6 +76,29 @@ export default class SecretDeleteMenu extends Component { v2UpdatePath; @alias('v2UpdatePath.canDelete') canDestroyAllVersions; + @maybeQueryRecord( + 'capabilities', + context => { + if (!context.args.model || context.args.mode === 'create') { + return; + } + let backend = context.args.isV2 ? context.args.model.engine.id : context.args.model.backend; + let id = context.args.model.id; + let path = context.args.isV2 + ? `${encodeURIComponent(backend)}/data/${encodeURIComponent(id)}` + : `${encodeURIComponent(backend)}/${encodeURIComponent(id)}`; + return { + id: path, + }; + }, + 'isV2', + 'model', + 'model.id', + 'mode' + ) + secretDataPath; + @alias('secretDataPath.canDelete') canDeleteSecretData; + get isLatestVersion() { let { model } = this.args; if (!model) return false; @@ -113,13 +112,16 @@ export default class SecretDeleteMenu extends Component { @action handleDelete(deleteType) { - // deleteType should be 'delete', 'destroy', 'undelete', 'delete-latest-version', 'destroy-all-versions' + // deleteType should be 'delete', 'destroy', 'undelete', 'delete-latest-version', 'destroy-all-versions', 'v1' if (!deleteType) { return; } if (deleteType === 'destroy-all-versions' || deleteType === 'v1') { let { id } = this.args.model; this.args.model.destroyRecord().then(() => { + if (deleteType === 'v1') { + return this.router.transitionTo('vault.cluster.secrets.backend.list-root'); + } this.args.navToNearestAncestor.perform(id); }); } else { diff --git a/ui/app/components/secret-edit-metadata.js b/ui/app/components/secret-edit-metadata.js new file mode 100644 index 000000000..e62e1e924 --- /dev/null +++ b/ui/app/components/secret-edit-metadata.js @@ -0,0 +1,79 @@ +/** + * @module SecretEditMetadata + * + * @example + * ```js + * + * ``` + * + * @param {object} model - name of the current cluster, passed from the parent. + * @param {string} mode - if the mode is create, show, edit. + * @param {Function} [updateValidationErrorCount] - function on parent that handles disabling the save button. + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { set } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class SecretEditMetadata extends Component { + @service router; + @service store; + + @tracked validationErrorCount = 0; + + constructor() { + super(...arguments); + this.validationMessages = { + customMetadata: '', + maxVersions: '', + }; + } + + async save() { + let model = this.args.model; + try { + await model.save(); + } catch (e) { + this.error = e; + return; + } + this.router.transitionTo('vault.cluster.secrets.backend.metadata', this.args.model.id); + } + + @action + onSaveChanges(event) { + event.preventDefault(); + return this.save(); + } + @action onKeyUp(name, value) { + if (value) { + if (name === 'customMetadata') { + // cp validations won't work on an object so performing validations here + /* eslint-disable no-useless-escape */ + let regex = /^[^\\]+$/g; // looking for a backward slash + value.match(regex) + ? set(this.validationMessages, name, '') + : set(this.validationMessages, name, 'Custom values cannot contain a backward slash.'); + } + if (name === 'maxVersions') { + this.args.model.maxVersions = value; + this.args.model.validations.attrs.maxVersions.isValid + ? set(this.validationMessages, name, '') + : set(this.validationMessages, name, this.args.model.validations.attrs.maxVersions.message); + } + } + + let values = Object.values(this.validationMessages); + this.validationErrorCount = values.filter(Boolean).length; + // when mode is "update" this works, but on mode "create" we need to bubble up the count + if (this.args.updateValidationErrorCount) { + this.args.updateValidationErrorCount(this.validationErrorCount); + } + } +} diff --git a/ui/app/components/secret-edit-toolbar.js b/ui/app/components/secret-edit-toolbar.js new file mode 100644 index 000000000..09e2433f1 --- /dev/null +++ b/ui/app/components/secret-edit-toolbar.js @@ -0,0 +1,108 @@ +/** + * @module SecretEditToolbar + * SecretEditToolbar component is the toolbar component displaying the JSON toggle and the actions like delete in the show mode. + * + * @example + * ```js + * + * ``` + + * @param {string} mode - show, create, edit. The view. + * @param {object} model - the model passed from the parent secret-edit + * @param {boolean} isV2 - KV type + * @param {boolean} isWriteWithoutRead - boolean describing permissions + * @param {boolean} secretDataIsAdvanced - used to determine if show JSON toggle + * @param {boolean} showAdvacnedMode - used for JSON toggle + * @param {object} modelForData - a modified version of the model with secret data + * @param {string} navToNearestAncestor - route to nav to if press cancel + * @param {boolean} canUpdateSecretData - permissions that show the create new version button or not. + * @param {string} codemirrorString - used to copy the JSON + * @param {object} wrappedData - when copy the data it's the token of the secret returned. + * @param {object} editActions - actions passed from parent to child + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { not } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class SecretEditToolbar extends Component { + @service store; + @service flashMessages; + + @tracked wrappedData = null; + @tracked isWrapping = false; + @not('wrappedData') showWrapButton; + + @action + clearWrappedData() { + this.wrappedData = null; + } + + @action + handleCopyError() { + this.flashMessages.danger('Could Not Copy Wrapped Data'); + this.send('clearWrappedData'); + } + + @action + handleCopySuccess() { + this.flashMessages.success('Copied Wrapped Data!'); + this.send('clearWrappedData'); + } + + @action + handleWrapClick() { + this.isWrapping = true; + if (this.args.isV2) { + this.store + .adapterFor('secret-v2-version') + .queryRecord(this.args.modelForData.id, { wrapTTL: 1800 }) + .then(resp => { + this.wrappedData = resp.wrap_info.token; + this.flashMessages.success('Secret Successfully Wrapped!'); + }) + .catch(() => { + this.flashMessages.danger('Could Not Wrap Secret'); + }) + .finally(() => { + this.isWrapping = false; + }); + } else { + this.store + .adapterFor('secret') + .queryRecord(null, null, { + backend: this.args.model.backend, + id: this.args.modelForData.id, + wrapTTL: 1800, + }) + .then(resp => { + this.wrappedData = resp.wrap_info.token; + this.flashMessages.success('Secret Successfully Wrapped!'); + }) + .catch(() => { + this.flashMessages.danger('Could Not Wrap Secret'); + }) + .finally(() => { + this.isWrapping = false; + }); + } + } +} diff --git a/ui/app/components/secret-edit.js b/ui/app/components/secret-edit.js index d8ca7ccea..f49ac0e77 100644 --- a/ui/app/components/secret-edit.js +++ b/ui/app/components/secret-edit.js @@ -1,27 +1,27 @@ -import Ember from 'ember'; -import { isBlank, isNone } from '@ember/utils'; +/** + * @module SecretEdit + * SecretEdit component manages the secret and model data, and displays either the create, update, empty state or show view of a KV secret. + * + * @example + * ```js + * + * ``` +/ + * @param {object} model - Model returned from route secret-v2 + */ + import { inject as service } from '@ember/service'; import Component from '@ember/component'; -import { computed, set } from '@ember/object'; -import { alias, or, not } from '@ember/object/computed'; -import { task, waitForEvent } from 'ember-concurrency'; +import { computed } from '@ember/object'; +import { alias, or } from '@ember/object/computed'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; import WithNavToNearestAncestor from 'vault/mixins/with-nav-to-nearest-ancestor'; -import keys from 'vault/lib/keycodes'; import KVObject from 'vault/lib/kv-object'; import { maybeQueryRecord } from 'vault/macros/maybe-query-record'; -import ControlGroupError from 'vault/lib/control-group-error'; - -const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; -const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; -const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { wizard: service(), - controlGroup: service(), - router: service(), store: service(), - flashMessages: service(), // a key model key: null, @@ -36,11 +36,7 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { secretData: null, - wrappedData: null, - isWrapping: false, - showWrapButton: not('wrappedData'), - - // called with a bool indicating if there's been a change in the secretData + // called with a bool indicating if there's been a change in the secretData and customMetadata onDataChange() {}, onRefresh() {}, onToggleAdvancedEdit() {}, @@ -51,19 +47,11 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { // use a named action here so we don't have to pass one in // this will bubble to the route toggleAdvancedEdit: 'toggleAdvancedEdit', - error: null, codemirrorString: null, - hasLintError: false, isV2: false, - // cp-validation related properties - validationMessages: null, - validationErrorCount: 0, - - secretPaths: null, - init() { this._super(...arguments); let secrets = this.model.secretData; @@ -77,42 +65,13 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { if (data.isAdvanced()) { this.set('preferAdvancedEdit', true); } - this.checkRows(); if (this.wizard.featureState === 'details' && this.mode === 'create') { let engine = this.model.backend.includes('kv') ? 'kv' : this.model.backend; this.wizard.transitionFeatureMachine('details', 'CONTINUE', engine); } - if (this.mode === 'edit') { - this.send('addRow'); - } - this.set('validationMessages', { - path: '', - maxVersions: '', - }); - // for validation, return array of path names already assigned - if (Ember.testing) { - this.set('secretPaths', ['beep', 'bop', 'boop']); - } else { - let adapter = this.store.adapterFor('secret-v2'); - let type = { modelName: 'secret-v2' }; - let query = { backend: this.model.backend }; - adapter.query(this.store, type, query).then(result => { - this.set('secretPaths', result.data.keys); - }); - } }, - waitForKeyUp: task(function*(name, value) { - this.checkValidation(name, value); - while (true) { - let event = yield waitForEvent(document.body, 'keyup'); - this.onEscape(event); - } - }) - .on('didInsertElement') - .cancelOn('willDestroyElement'), - - updatePath: maybeQueryRecord( + checkSecretCapabilities: maybeQueryRecord( 'capabilities', context => { if (!context.model || context.mode === 'create') { @@ -130,19 +89,18 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { 'model.id', 'mode' ), - canDelete: alias('updatePath.canDelete'), - canEdit: alias('updatePath.canUpdate'), + canUpdateSecretData: alias('checkSecretCapabilities.canUpdate'), - v2UpdatePath: maybeQueryRecord( + checkMetadataCapabilities: maybeQueryRecord( 'capabilities', context => { - if (!context.model || context.mode === 'create' || context.isV2 === false) { + if (!context.model || !context.isV2) { return; } - let backend = context.get('model.engine.id'); - let id = context.model.id; + let backend = context.model.backend; + let path = `${backend}/metadata/`; return { - id: `${backend}/metadata/${id}`, + id: path, }; }, 'isV2', @@ -150,11 +108,12 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { 'model.id', 'mode' ), - canEditV2Secret: alias('v2UpdatePath.canUpdate'), + canDeleteSecretMetadata: alias('checkMetadataCapabilities.canDelete'), + canCreateSecretMetadata: alias('checkMetadataCapabilities.canCreate'), requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'), - buttonDisabled: or('requestInFlight', 'model.isFolder', 'model.flagsIsInvalid', 'hasLintError', 'error'), + buttonDisabled: or('requestInFlight', 'model.isFolder', 'model.flagsIsInvalid'), modelForData: computed('isV2', 'model', function() { let { model } = this; @@ -189,239 +148,13 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, { return false; }), - transitionToRoute() { - return this.router.transitionTo(...arguments); - }, - - checkValidation(name, value) { - if (name === 'path') { - !value - ? set(this.validationMessages, name, `${name} can't be blank.`) - : set(this.validationMessages, name, ''); - } - // check duplicate on path - if (name === 'path' && value) { - this.secretPaths?.includes(value) - ? set(this.validationMessages, name, `A secret with this ${name} already exists.`) - : set(this.validationMessages, name, ''); - } - // check maxVersions is a number - if (name === 'maxVersions') { - // checking for value because value which is blank on first load. No keyup event has occurred and default is 10. - if (value) { - let number = Number(value); - this.model.set('maxVersions', number); - } - if (!this.model.validations.attrs.maxVersions.isValid) { - set(this.validationMessages, name, this.model.validations.attrs.maxVersions.message); - } else { - set(this.validationMessages, name, ''); - } - } - - let values = Object.values(this.validationMessages); - - this.set('validationErrorCount', values.filter(Boolean).length); - }, - - onEscape(e) { - if (e.keyCode !== keys.ESC || this.mode !== 'show') { - return; - } - const parentKey = this.model.parentKey; - if (parentKey) { - this.transitionToRoute(LIST_ROUTE, parentKey); - } else { - this.transitionToRoute(LIST_ROOT_ROUTE); - } - }, - - // successCallback is called in the context of the component - persistKey(successCallback) { - let secret = this.model; - let secretData = this.modelForData; - let isV2 = this.isV2; - let key = secretData.get('path') || secret.id; - - if (key.startsWith('/')) { - key = key.replace(/^\/+/g, ''); - secretData.set(secretData.pathAttr, key); - } - - return secretData - .save() - .then(() => { - if (!secretData.isError) { - if (isV2) { - secret.set('id', key); - } - if (isV2 && Object.keys(secret.changedAttributes()).length) { - // save secret metadata - secret - .save() - .then(() => { - this.saveComplete(successCallback, key); - }) - .catch(e => { - this.set(e, e.errors.join(' ')); - }); - } else { - this.saveComplete(successCallback, key); - } - } - }) - .catch(error => { - if (error instanceof ControlGroupError) { - let errorMessage = this.controlGroup.logFromError(error); - this.set('error', errorMessage.content); - } - throw error; - }); - }, - saveComplete(callback, key) { - if (this.wizard.featureState === 'secret') { - this.wizard.transitionFeatureMachine('secret', 'CONTINUE'); - } - callback(key); - }, - - checkRows() { - if (this.secretData.length === 0) { - this.send('addRow'); - } - }, - actions: { - //submit on shift + enter - handleKeyDown(e) { - e.stopPropagation(); - if (!(e.keyCode === keys.ENTER && e.metaKey)) { - return; - } - let $form = this.element.querySelector('form'); - if ($form.length) { - $form.submit(); - } - }, - - handleChange() { - this.set('codemirrorString', this.secretData.toJSONString(true)); - set(this.modelForData, 'secretData', this.secretData.toJSON()); - }, - - handleWrapClick() { - this.set('isWrapping', true); - if (this.isV2) { - this.store - .adapterFor('secret-v2-version') - .queryRecord(this.modelForData.id, { wrapTTL: 1800 }) - .then(resp => { - this.set('wrappedData', resp.wrap_info.token); - this.flashMessages.success('Secret Successfully Wrapped!'); - }) - .catch(() => { - this.flashMessages.danger('Could Not Wrap Secret'); - }) - .finally(() => { - this.set('isWrapping', false); - }); - } else { - this.store - .adapterFor('secret') - .queryRecord(null, null, { backend: this.model.backend, id: this.modelForData.id, wrapTTL: 1800 }) - .then(resp => { - this.set('wrappedData', resp.wrap_info.token); - this.flashMessages.success('Secret Successfully Wrapped!'); - }) - .catch(() => { - this.flashMessages.danger('Could Not Wrap Secret'); - }) - .finally(() => { - this.set('isWrapping', false); - }); - } - }, - - clearWrappedData() { - this.set('wrappedData', null); - }, - - handleCopySuccess() { - this.flashMessages.success('Copied Wrapped Data!'); - this.send('clearWrappedData'); - }, - - handleCopyError() { - this.flashMessages.danger('Could Not Copy Wrapped Data'); - this.send('clearWrappedData'); - }, - - createOrUpdateKey(type, event) { - event.preventDefault(); - let model = this.modelForData; - if (type === 'create' && isBlank(model.path || model.id)) { - this.checkValidation('path', ''); - return; - } - - this.persistKey(() => { - this.transitionToRoute(SHOW_ROUTE, this.model.path || this.model.id); - }); - }, - - deleteKey() { - let { id } = this.model; - this.model.destroyRecord().then(() => { - this.navToNearestAncestor.perform(id); - }); - }, - refresh() { this.onRefresh(); }, - addRow() { - const data = this.secretData; - if (isNone(data.findBy('name', ''))) { - data.pushObject({ name: '', value: '' }); - this.send('handleChange'); - } - this.checkRows(); - }, - - deleteRow(name) { - const data = this.secretData; - const item = data.findBy('name', name); - if (isBlank(item.name)) { - return; - } - data.removeObject(item); - this.checkRows(); - this.send('handleChange'); - }, - toggleAdvanced(bool) { this.onToggleAdvancedEdit(bool); }, - - codemirrorUpdated(val, codemirror) { - this.set('error', null); - codemirror.performLint(); - const noErrors = codemirror.state.lint.marked.length === 0; - if (noErrors) { - try { - this.secretData.fromJSONString(val); - set(this.modelForData, 'secretData', this.secretData.toJSON()); - } catch (e) { - this.set('error', e.message); - } - } - this.set('hasLintError', !noErrors); - this.set('codemirrorString', val); - }, - - formatJSON() { - this.set('codemirrorString', this.secretData.toJSONString(true)); - }, }, }); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/metadata.js b/ui/app/controllers/vault/cluster/secrets/backend/metadata.js new file mode 100644 index 000000000..d97faf541 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/metadata.js @@ -0,0 +1,10 @@ +import Controller from '@ember/controller'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; +import { action } from '@ember/object'; + +export default class MetadataController extends Controller.extend(BackendCrumbMixin) { + @action + refreshModel() { + this.send('refreshModel'); + } +} diff --git a/ui/app/helpers/split-object.js b/ui/app/helpers/split-object.js new file mode 100644 index 000000000..8da0401f1 --- /dev/null +++ b/ui/app/helpers/split-object.js @@ -0,0 +1,32 @@ +/** + * @module SplitObject + * SplitObject helper takes in a class of data as the first param and an array of keys that you want to split into another object as the second param. + * You will end up with an array of two objects. One no longer with the array of params, and the second with just the array of params. + * + * @example + * ```js + * splitObject(data, ['max_versions', 'delete_version_after', 'cas_required']) + * ``` + + * @param {object} - The object you want to split into two. This object will have all the keys from the second param (the array param). + * @param {array} - An array of params that you want to split off the object and turn into its own object. + + */ +import { helper as buildHelper } from '@ember/component/helper'; + +export function splitObject(originalObject, array) { + let object1 = {}; + let object2 = {}; + // convert object to key's array + let keys = Object.keys(originalObject); + keys.forEach(key => { + if (array.includes(key)) { + object1[key] = originalObject[key]; + } else { + object2[key] = originalObject[key]; + } + }); + return [object1, object2]; +} + +export default buildHelper(splitObject); diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index e1da82f77..0adac7f39 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -13,6 +13,18 @@ const Validations = buildValidations({ presence: true, message: "Path can't be blank.", }), + maxVersions: [ + validator('number', { + allowString: true, + integer: true, + message: 'Maximum versions must be a number.', + }), + validator('length', { + min: 1, + max: 16, + message: 'You cannot go over 16 characters.', + }), + ], }); export default Model.extend(Validations, { @@ -35,6 +47,26 @@ export default Model.extend(Validations, { helpText: 'When enabled - if a seal supporting seal wrapping is specified in the configuration, all critical security parameters (CSPs) in this backend will be seal wrapped. (For K/V mounts, all values will be seal wrapped.) This can only be specified at mount time.', }), + // KV 2 additional config default options + maxVersions: attr('number', { + defaultValue: 10, + label: 'Maximum number of versions', + subText: + 'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted. This value applies to all keys, but a key’s metadata settings can overwrite this value.', + }), + casRequired: attr('boolean', { + defaultValue: false, + label: 'Require Check and Set', + subText: + 'If checked, all keys will require the cas parameter to be set on all write requests. A key’s metadata settings can overwrite this value.', + }), + deleteVersionAfter: attr({ + defaultValue: 0, + editType: 'ttl', + label: 'Automate secret deletion', + helperTextDisabled: 'A secret’s version must be manually deleted.', + helperTextEnabled: 'Delete all new versions of this secret after', + }), modelTypeForKV: computed('engineType', 'options.version', function() { let type = this.engineType; @@ -67,7 +99,13 @@ export default Model.extend(Validations, { formFieldGroups: computed('engineType', function() { let type = this.engineType; - let defaultGroup = { default: ['path'] }; + let defaultGroup; + // KV has specific config options it adds on the enable engine. https://www.vaultproject.io/api/secret/kv/kv-v2#configure-the-kv-engine + if (type === 'kv') { + defaultGroup = { default: ['path', 'maxVersions', 'casRequired', 'deleteVersionAfter'] }; + } else { + defaultGroup = { default: ['path'] }; + } let optionsGroup = { 'Method Options': [ 'description', diff --git a/ui/app/models/secret-v2.js b/ui/app/models/secret-v2.js index d6ed6c28d..c8f5e97ca 100644 --- a/ui/app/models/secret-v2.js +++ b/ui/app/models/secret-v2.js @@ -9,7 +9,7 @@ import { validator, buildValidations } from 'ember-cp-validations'; const Validations = buildValidations({ maxVersions: [ validator('number', { - allowString: false, + allowString: true, integer: true, message: 'Maximum versions must be a number.', }), @@ -31,23 +31,40 @@ export default Model.extend(KeyMixin, Validations, { updatedTime: attr(), currentVersion: attr('number'), oldestVersion: attr('number'), + customMetadata: attr('object', { + editType: 'kv', + subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', + }), maxVersions: attr('number', { defaultValue: 10, - label: 'Maximum Number of Versions', + label: 'Maximum number of versions', + subText: + 'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.', }), casRequired: attr('boolean', { defaultValue: false, label: 'Require Check and Set', - helpText: - 'Writes will only be allowed if the key’s current version matches the version specified in the cas parameter', + subText: + 'Writes will only be allowed if the key’s current version matches the version specified in the cas parameter.', + }), + deleteVersionAfter: attr({ + defaultValue: 0, + editType: 'ttl', + label: 'Automate secret deletion', + helperTextDisabled: 'A secret’s version must be manually deleted.', + helperTextEnabled: 'Delete all new versions of this secret after', }), fields: computed(function() { - return expandAttributeMeta(this, ['maxVersions', 'casRequired']); + return expandAttributeMeta(this, ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']); }), - versionPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'), - secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'), + secretDataPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'), + secretMetadataPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'), - canEdit: alias('versionPath.canUpdate'), - canDelete: alias('secretPath.canDelete'), - canRead: alias('secretPath.canRead'), + canListMetadata: alias('secretMetadataPath.canList'), + canReadMetadata: alias('secretMetadataPath.canRead'), + canUpdateMetadata: alias('secretMetadataPath.canUpdate'), + + canReadSecretData: alias('secretDataPath.canRead'), + canEditSecretData: alias('secretDataPath.canUpdate'), + canDeleteSecretData: alias('secretDataPath.canDelete'), }); diff --git a/ui/app/router.js b/ui/app/router.js index cfa018b0c..ee964bfb7 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -94,7 +94,7 @@ Router.map(function() { this.route('index', { path: '/' }); this.route('configuration'); // because globs / params can't be empty, - // we have to special-case ids of '' with thier own routes + // we have to special-case ids of '' with their own routes this.route('list-root', { path: '/list/' }); this.route('create-root', { path: '/create/' }); this.route('show-root', { path: '/show/' }); @@ -102,6 +102,8 @@ Router.map(function() { this.route('list', { path: '/list/*secret' }); this.route('show', { path: '/show/*secret' }); + this.route('metadata', { path: '/metadata/*secret' }); + this.route('edit-metadata', { path: '/edit-metadata/*secret' }); this.route('create', { path: '/create/*secret' }); this.route('edit', { path: '/edit/*secret' }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration.js b/ui/app/routes/vault/cluster/secrets/backend/configuration.js index 88985ddf7..1cd8b9212 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration.js +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration.js @@ -3,11 +3,46 @@ import Route from '@ember/routing/route'; export default Route.extend({ wizard: service(), + store: service(), model() { let backend = this.modelFor('vault.cluster.secrets.backend'); if (this.wizard.featureState === 'list') { this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', backend.get('type')); } + // if KV2 then we pull in specific attrs from the config endpoint saved on the secret-engine record and display them + if (backend.isV2KV) { + let secretEngineRecord = this.store.peekRecord('secret-engine', backend.id); + // create objects like you would normally pull from the model + let casRequired = { + name: 'casRequired', + options: { + label: 'Check-and-Set required', + }, + }; + let deleteVersionAfter = { + name: 'deleteVersionAfter', + options: { + label: 'Delete version after', + }, + }; + let maxVersions = { + name: 'maxVersions', + options: { + label: 'Maximum versions', + }, + }; + backend.attrs.pushObject(casRequired); + backend.attrs.pushObject(deleteVersionAfter); + backend.attrs.pushObject(maxVersions); + // set value on the model + backend.set('casRequired', secretEngineRecord.casRequired ? secretEngineRecord.casRequired : 'False'); + backend.set( + 'deleteVersionAfter', + secretEngineRecord.deleteVersionAfter ? secretEngineRecord.deleteVersionAfter : 'Never delete' + ); + backend.set('maxVersions', secretEngineRecord.maxVersions ? secretEngineRecord.maxVersions : 'Not set'); + } + return backend; }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index 37bb71458..75c7a5140 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -47,7 +47,12 @@ export default EditBase.extend({ } return this.store.createRecord(modelType); } - + // create record in capabilities that checks for access to create metadata + // this record is then maybeQueryRecord in the component secret-create-or-update + if (modelType === 'secret-v2') { + // only check for kv2 secrets + this.store.findRecord('capabilities', `${backend}/metadata/`); + } return secretModel(this.store, backend, transition.to.queryParams.initialKey); }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/edit-metadata.js b/ui/app/routes/vault/cluster/secrets/backend/edit-metadata.js new file mode 100644 index 000000000..2d033069d --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/edit-metadata.js @@ -0,0 +1,3 @@ +import Metadata from './metadata'; + +export default class EditMetadataRoute extends Metadata {} diff --git a/ui/app/routes/vault/cluster/secrets/backend/metadata.js b/ui/app/routes/vault/cluster/secrets/backend/metadata.js new file mode 100644 index 000000000..b47b0ddc9 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/metadata.js @@ -0,0 +1,24 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class MetadataShow extends Route { + @service store; + + beforeModel() { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + this.backend = backend; + } + + model(params) { + let { secret } = params; + return this.store.queryRecord('secret-v2', { + backend: this.backend, + id: secret, + }); + } + + setupController(controller, model) { + controller.set('backend', this.backend); // for backendCrumb + controller.set('model', model); + } +} diff --git a/ui/app/styles/components/form-section.scss b/ui/app/styles/components/form-section.scss index 81f8eedbb..a6bbe524c 100644 --- a/ui/app/styles/components/form-section.scss +++ b/ui/app/styles/components/form-section.scss @@ -1,6 +1,10 @@ .form-section { padding: 1.75rem 0; box-shadow: 0 -1px 0 0 rgba($black, 0.1); + + > p.has-padding-bottom { + padding-bottom: 1.5rem; + } } .field:first-child .form-section { diff --git a/ui/app/styles/core/box.scss b/ui/app/styles/core/box.scss index 277c55ed3..fc7cec98c 100644 --- a/ui/app/styles/core/box.scss +++ b/ui/app/styles/core/box.scss @@ -1,5 +1,16 @@ .box { box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); + + .title { + &.has-padding-top { + padding-top: $spacing-m; + } + } + p { + &.has-padding-bottom { + padding-bottom: $spacing-s; + } + } } .box.is-fullwidth { padding-left: 0; @@ -20,7 +31,11 @@ .box.is-rounded { border-radius: 3px; } - .box.no-top-shadow { box-shadow: inset 0 -1px 0 0 rgba($black, 0.1); } +.box.has-container { + box-shadow: 0 4px 4px rgba($black, 0.25); + border: 1px solid #bac1cc; + padding: $spacing-l; +} diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 75c4efb78..004554849 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -58,6 +58,10 @@ label { margin-left: $size-10; } +.b-checkbox > .sub-text { + padding-left: 2rem; +} + .help { &.is-danger { font-weight: $weight-bold; diff --git a/ui/app/templates/components/kv-object-editor.hbs b/ui/app/templates/components/kv-object-editor.hbs index 6c3d33dc4..7e323cf4e 100644 --- a/ui/app/templates/components/kv-object-editor.hbs +++ b/ui/app/templates/components/kv-object-editor.hbs @@ -1,12 +1,26 @@ {{#if label}} -