From 9e4a095c71875dc8a11d4806b580db184b5a5026 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 | 287 +++------------- .../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 | 19 +- .../components/secret-edit-test.js | 1 + .../pages/secrets/backend/kv/edit-secret.js | 5 +- 35 files changed, 1428 insertions(+), 735 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 0000000000000..6842cbd1d6c42 --- /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 21f926a4587d2..c2bd214f089bc 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 a8da4b35c3434..46e45d2040ec8 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 5280ec75dab2e..0bd3f446ec52a 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 0000000000000..f58e9605e7851 --- /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 e5feb32e76344..e20b4828030a6 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 0000000000000..e62e1e9243032 --- /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 0000000000000..09e2433f16290 --- /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 d8ca7cceaffce..f49ac0e770c9a 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 0000000000000..d97faf541775f --- /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 0000000000000..8da0401f1fe4b --- /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 e1da82f77cd62..0adac7f398822 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 d6ed6c28df28e..c8f5e97ca9962 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'), + + canListMetadata: alias('secretMetadataPath.canList'), + canReadMetadata: alias('secretMetadataPath.canRead'), + canUpdateMetadata: alias('secretMetadataPath.canUpdate'), - canEdit: alias('versionPath.canUpdate'), - canDelete: alias('secretPath.canDelete'), - canRead: alias('secretPath.canRead'), + 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 cfa018b0c572d..ee964bfb77ac5 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 88985ddf75ca5..1cd8b92123947 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 37bb714580b7a..75c7a5140bfa8 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 0000000000000..2d033069d3d1c --- /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 0000000000000..b47b0ddc9e21e --- /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 81f8eedbb2810..a6bbe524c22c5 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 277c55ed3507f..fc7cec98cfce2 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 75c4efb78c034..004554849dc1f 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 6c3d33dc42cda..7e323cf4e9f20 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}} -