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
This commit is contained in:
Angel Garbarino 2021-08-31 09:41:41 -06:00 committed by GitHub
parent c99cf35b6a
commit c013e4a741
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1430 additions and 737 deletions

3
changelog/12169.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Add custom metadata to KV secret engine and metadata to config
```

View File

@ -1,6 +1,7 @@
import { assign } from '@ember/polyfills'; import { assign } from '@ember/polyfills';
import ApplicationAdapter from './application'; import ApplicationAdapter from './application';
import { encodePath } from 'vault/utils/path-encoding-helpers'; import { encodePath } from 'vault/utils/path-encoding-helpers';
import { splitObject } from 'vault/helpers/split-object';
export default ApplicationAdapter.extend({ export default ApplicationAdapter.extend({
url(path) { url(path) {
@ -8,6 +9,10 @@ export default ApplicationAdapter.extend({
return path ? url + '/' + encodePath(path) : url; return path ? url + '/' + encodePath(path) : url;
}, },
urlForConfig(path) {
return `/v1/${path}/config`;
},
internalURL(path) { internalURL(path) {
let url = `/${this.urlPrefix()}/internal/ui/mounts`; let url = `/${this.urlPrefix()}/internal/ui/mounts`;
if (path) { if (path) {
@ -26,15 +31,37 @@ export default ApplicationAdapter.extend({
createRecord(store, type, snapshot) { createRecord(store, type, snapshot) {
const serializer = store.serializerFor(type.modelName); const serializer = store.serializerFor(type.modelName);
const data = serializer.serialize(snapshot); let data = serializer.serialize(snapshot);
const path = snapshot.attr('path'); const path = snapshot.attr('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(() => { return this.ajax(this.url(path), 'POST', { data }).then(() => {
// ember data doesn't like 204s if it's not a DELETE // ember data doesn't like 204s if it's not a DELETE
return { return {
data: assign({}, data, { path: path + '/', id: path }), data: assign({}, data, { path: path + '/', id: path }),
}; };
}); });
}
}, },
findRecord(store, type, path, snapshot) { findRecord(store, type, path, snapshot) {

View File

@ -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
* <KvObjectEditor
* @value={{get model valuePath}}
* @onChange={{action "setAndBroadcast" valuePath }}
* @label="some label"
/>
* ```
* @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 { isNone } from '@ember/utils';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
import Component from '@ember/component'; import Component from '@ember/component';
@ -7,12 +30,15 @@ import KVObject from 'vault/lib/kv-object';
export default Component.extend({ export default Component.extend({
'data-test-component': 'kv-object-editor', 'data-test-component': 'kv-object-editor',
classNames: ['field', 'form-section'], classNames: ['field'],
classNameBindings: ['formSection:form-section'],
formSection: true,
// public API // public API
// Ember Object to mutate // Ember Object to mutate
value: null, value: null,
label: null, label: null,
helpText: null, helpText: null,
subText: null,
// onChange will be called with the changed Value // onChange will be called with the changed Value
onChange() {}, onChange() {},
@ -65,5 +91,12 @@ export default Component.extend({
data.removeAt(index); data.removeAt(index);
this.onChange(data.toJSON()); this.onChange(data.toJSON());
}, },
handleKeyUp(name, value) {
if (!this.onKeyUp) {
return;
}
this.onKeyUp(name, value);
},
}, },
}); });

View File

@ -108,11 +108,24 @@ export default Component.extend({
actions: { actions: {
onKeyUp(name, value) { onKeyUp(name, value) {
// validate path
if (name === 'path') {
this.mountModel.set('path', value); this.mountModel.set('path', value);
this.mountModel.validations.attrs.path.isValid this.mountModel.validations.attrs.path.isValid
? set(this.validationMessages, 'path', '') ? set(this.validationMessages, 'path', '')
: set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message); : 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.mountModel.validate().then(({ validations }) => {
this.set('isFormInvalid', !validations.isValid); this.set('isFormInvalid', !validations.isValid);
}); });

View File

@ -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
* <SecretCreateOrUpdate
* @mode="create"
* @model={{model}}
* @showAdvancedMode=true
* @modelForData={{@modelForData}}
* @isV2=true
* @secretData={{@secretData}}
* @canCreateSecretMetadata=true
* />
* ```
* @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;
}
}

View File

@ -17,30 +17,6 @@ export default class SecretDeleteMenu extends Component {
@tracked showDeleteModal = false; @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( @maybeQueryRecord(
'capabilities', 'capabilities',
context => { context => {
@ -100,6 +76,29 @@ export default class SecretDeleteMenu extends Component {
v2UpdatePath; v2UpdatePath;
@alias('v2UpdatePath.canDelete') canDestroyAllVersions; @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() { get isLatestVersion() {
let { model } = this.args; let { model } = this.args;
if (!model) return false; if (!model) return false;
@ -113,13 +112,16 @@ export default class SecretDeleteMenu extends Component {
@action @action
handleDelete(deleteType) { 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) { if (!deleteType) {
return; return;
} }
if (deleteType === 'destroy-all-versions' || deleteType === 'v1') { if (deleteType === 'destroy-all-versions' || deleteType === 'v1') {
let { id } = this.args.model; let { id } = this.args.model;
this.args.model.destroyRecord().then(() => { this.args.model.destroyRecord().then(() => {
if (deleteType === 'v1') {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root');
}
this.args.navToNearestAncestor.perform(id); this.args.navToNearestAncestor.perform(id);
}); });
} else { } else {

View File

@ -0,0 +1,79 @@
/**
* @module SecretEditMetadata
*
* @example
* ```js
* <SecretEditMetadata
* @model={{model}}
* @mode={{mode}}
* @updateValidationErrorCount={{updateValidationErrorCount}}
* />
* ```
*
* @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);
}
}
}

View File

@ -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
* <SecretEditToolbar
* @mode={{mode}}
* @model={{this.model}}
* @isV2={{isV2}}
* @isWriteWithoutRead={{isWriteWithoutRead}}
* @secretDataIsAdvanced={{secretDataIsAdvanced}}
* @showAdvancedMode={{showAdvancedMode}}
* @modelForData={{this.modelForData}}
* @navToNearestAncestor={{this.navToNearestAncestor}}
* @canUpdateSecretData={{canUpdateSecretData}}
* @codemirrorString={{codemirrorString}}
* @wrappedData={{wrappedData}}
* @editActions={{hash
toggleAdvanced=(action "toggleAdvanced")
refresh=(action "refresh")
}}
* />
* ```
* @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;
});
}
}
}

View File

@ -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
* <SecretEdit @model={{model}}/>
* ```
/
* @param {object} model - Model returned from route secret-v2
*/
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import Component from '@ember/component'; import Component from '@ember/component';
import { computed, set } from '@ember/object'; import { computed } from '@ember/object';
import { alias, or, not } from '@ember/object/computed'; import { alias, or } from '@ember/object/computed';
import { task, waitForEvent } from 'ember-concurrency';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import WithNavToNearestAncestor from 'vault/mixins/with-nav-to-nearest-ancestor'; import WithNavToNearestAncestor from 'vault/mixins/with-nav-to-nearest-ancestor';
import keys from 'vault/lib/keycodes';
import KVObject from 'vault/lib/kv-object'; import KVObject from 'vault/lib/kv-object';
import { maybeQueryRecord } from 'vault/macros/maybe-query-record'; 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, { export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
wizard: service(), wizard: service(),
controlGroup: service(),
router: service(),
store: service(), store: service(),
flashMessages: service(),
// a key model // a key model
key: null, key: null,
@ -36,11 +36,7 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
secretData: null, secretData: null,
wrappedData: null, // called with a bool indicating if there's been a change in the secretData and customMetadata
isWrapping: false,
showWrapButton: not('wrappedData'),
// called with a bool indicating if there's been a change in the secretData
onDataChange() {}, onDataChange() {},
onRefresh() {}, onRefresh() {},
onToggleAdvancedEdit() {}, 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 // use a named action here so we don't have to pass one in
// this will bubble to the route // this will bubble to the route
toggleAdvancedEdit: 'toggleAdvancedEdit', toggleAdvancedEdit: 'toggleAdvancedEdit',
error: null,
codemirrorString: null, codemirrorString: null,
hasLintError: false,
isV2: false, isV2: false,
// cp-validation related properties
validationMessages: null,
validationErrorCount: 0,
secretPaths: null,
init() { init() {
this._super(...arguments); this._super(...arguments);
let secrets = this.model.secretData; let secrets = this.model.secretData;
@ -77,42 +65,13 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
if (data.isAdvanced()) { if (data.isAdvanced()) {
this.set('preferAdvancedEdit', true); this.set('preferAdvancedEdit', true);
} }
this.checkRows();
if (this.wizard.featureState === 'details' && this.mode === 'create') { if (this.wizard.featureState === 'details' && this.mode === 'create') {
let engine = this.model.backend.includes('kv') ? 'kv' : this.model.backend; let engine = this.model.backend.includes('kv') ? 'kv' : this.model.backend;
this.wizard.transitionFeatureMachine('details', 'CONTINUE', engine); 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) { checkSecretCapabilities: maybeQueryRecord(
this.checkValidation(name, value);
while (true) {
let event = yield waitForEvent(document.body, 'keyup');
this.onEscape(event);
}
})
.on('didInsertElement')
.cancelOn('willDestroyElement'),
updatePath: maybeQueryRecord(
'capabilities', 'capabilities',
context => { context => {
if (!context.model || context.mode === 'create') { if (!context.model || context.mode === 'create') {
@ -130,19 +89,18 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
'model.id', 'model.id',
'mode' 'mode'
), ),
canDelete: alias('updatePath.canDelete'), canUpdateSecretData: alias('checkSecretCapabilities.canUpdate'),
canEdit: alias('updatePath.canUpdate'),
v2UpdatePath: maybeQueryRecord( checkMetadataCapabilities: maybeQueryRecord(
'capabilities', 'capabilities',
context => { context => {
if (!context.model || context.mode === 'create' || context.isV2 === false) { if (!context.model || !context.isV2) {
return; return;
} }
let backend = context.get('model.engine.id'); let backend = context.model.backend;
let id = context.model.id; let path = `${backend}/metadata/`;
return { return {
id: `${backend}/metadata/${id}`, id: path,
}; };
}, },
'isV2', 'isV2',
@ -150,11 +108,12 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
'model.id', 'model.id',
'mode' 'mode'
), ),
canEditV2Secret: alias('v2UpdatePath.canUpdate'), canDeleteSecretMetadata: alias('checkMetadataCapabilities.canDelete'),
canCreateSecretMetadata: alias('checkMetadataCapabilities.canCreate'),
requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'), 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() { modelForData: computed('isV2', 'model', function() {
let { model } = this; let { model } = this;
@ -189,239 +148,13 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
return false; 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: { 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() { refresh() {
this.onRefresh(); 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) { toggleAdvanced(bool) {
this.onToggleAdvancedEdit(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));
},
}, },
}); });

View File

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

View File

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

View File

@ -13,6 +13,18 @@ const Validations = buildValidations({
presence: true, presence: true,
message: "Path can't be blank.", 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, { export default Model.extend(Validations, {
@ -35,6 +47,26 @@ export default Model.extend(Validations, {
helpText: 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.', '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 keys 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 keys metadata settings can overwrite this value.',
}),
deleteVersionAfter: attr({
defaultValue: 0,
editType: 'ttl',
label: 'Automate secret deletion',
helperTextDisabled: 'A secrets version must be manually deleted.',
helperTextEnabled: 'Delete all new versions of this secret after',
}),
modelTypeForKV: computed('engineType', 'options.version', function() { modelTypeForKV: computed('engineType', 'options.version', function() {
let type = this.engineType; let type = this.engineType;
@ -67,7 +99,13 @@ export default Model.extend(Validations, {
formFieldGroups: computed('engineType', function() { formFieldGroups: computed('engineType', function() {
let type = this.engineType; 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 = { let optionsGroup = {
'Method Options': [ 'Method Options': [
'description', 'description',

View File

@ -9,7 +9,7 @@ import { validator, buildValidations } from 'ember-cp-validations';
const Validations = buildValidations({ const Validations = buildValidations({
maxVersions: [ maxVersions: [
validator('number', { validator('number', {
allowString: false, allowString: true,
integer: true, integer: true,
message: 'Maximum versions must be a number.', message: 'Maximum versions must be a number.',
}), }),
@ -31,23 +31,40 @@ export default Model.extend(KeyMixin, Validations, {
updatedTime: attr(), updatedTime: attr(),
currentVersion: attr('number'), currentVersion: attr('number'),
oldestVersion: 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', { maxVersions: attr('number', {
defaultValue: 10, 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', { casRequired: attr('boolean', {
defaultValue: false, defaultValue: false,
label: 'Require Check and Set', label: 'Require Check and Set',
helpText: subText:
'Writes will only be allowed if the keys current version matches the version specified in the cas parameter', 'Writes will only be allowed if the keys current version matches the version specified in the cas parameter.',
}),
deleteVersionAfter: attr({
defaultValue: 0,
editType: 'ttl',
label: 'Automate secret deletion',
helperTextDisabled: 'A secrets version must be manually deleted.',
helperTextEnabled: 'Delete all new versions of this secret after',
}), }),
fields: computed(function() { fields: computed(function() {
return expandAttributeMeta(this, ['maxVersions', 'casRequired']); return expandAttributeMeta(this, ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']);
}), }),
versionPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'), secretDataPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'),
secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'), secretMetadataPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'),
canEdit: alias('versionPath.canUpdate'), canListMetadata: alias('secretMetadataPath.canList'),
canDelete: alias('secretPath.canDelete'), canReadMetadata: alias('secretMetadataPath.canRead'),
canRead: alias('secretPath.canRead'), canUpdateMetadata: alias('secretMetadataPath.canUpdate'),
canReadSecretData: alias('secretDataPath.canRead'),
canEditSecretData: alias('secretDataPath.canUpdate'),
canDeleteSecretData: alias('secretDataPath.canDelete'),
}); });

View File

@ -94,7 +94,7 @@ Router.map(function() {
this.route('index', { path: '/' }); this.route('index', { path: '/' });
this.route('configuration'); this.route('configuration');
// because globs / params can't be empty, // 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('list-root', { path: '/list/' });
this.route('create-root', { path: '/create/' }); this.route('create-root', { path: '/create/' });
this.route('show-root', { path: '/show/' }); this.route('show-root', { path: '/show/' });
@ -102,6 +102,8 @@ Router.map(function() {
this.route('list', { path: '/list/*secret' }); this.route('list', { path: '/list/*secret' });
this.route('show', { path: '/show/*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('create', { path: '/create/*secret' });
this.route('edit', { path: '/edit/*secret' }); this.route('edit', { path: '/edit/*secret' });

View File

@ -3,11 +3,46 @@ import Route from '@ember/routing/route';
export default Route.extend({ export default Route.extend({
wizard: service(), wizard: service(),
store: service(),
model() { model() {
let backend = this.modelFor('vault.cluster.secrets.backend'); let backend = this.modelFor('vault.cluster.secrets.backend');
if (this.wizard.featureState === 'list') { if (this.wizard.featureState === 'list') {
this.wizard.transitionFeatureMachine(this.wizard.featureState, 'CONTINUE', backend.get('type')); 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; return backend;
}, },
}); });

View File

@ -47,7 +47,12 @@ export default EditBase.extend({
} }
return this.store.createRecord(modelType); 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); return secretModel(this.store, backend, transition.to.queryParams.initialKey);
}, },

View File

@ -0,0 +1,3 @@
import Metadata from './metadata';
export default class EditMetadataRoute extends Metadata {}

View File

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

View File

@ -1,6 +1,10 @@
.form-section { .form-section {
padding: 1.75rem 0; padding: 1.75rem 0;
box-shadow: 0 -1px 0 0 rgba($black, 0.1); box-shadow: 0 -1px 0 0 rgba($black, 0.1);
> p.has-padding-bottom {
padding-bottom: 1.5rem;
}
} }
.field:first-child .form-section { .field:first-child .form-section {

View File

@ -1,5 +1,16 @@
.box { .box {
box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); 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 { .box.is-fullwidth {
padding-left: 0; padding-left: 0;
@ -20,7 +31,11 @@
.box.is-rounded { .box.is-rounded {
border-radius: 3px; border-radius: 3px;
} }
.box.no-top-shadow { .box.no-top-shadow {
box-shadow: inset 0 -1px 0 0 rgba($black, 0.1); 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;
}

View File

@ -58,6 +58,10 @@ label {
margin-left: $size-10; margin-left: $size-10;
} }
.b-checkbox > .sub-text {
padding-left: 2rem;
}
.help { .help {
&.is-danger { &.is-danger {
font-weight: $weight-bold; font-weight: $weight-bold;

View File

@ -1,5 +1,5 @@
{{#if label}} {{#if label}}
<label class="title is-4" data-test-kv-label="true"> <label class="title {{if small-label 'is-5' 'is-4'}}" data-test-kv-label="true">
{{label}} {{label}}
{{#if helpText}} {{#if helpText}}
<InfoTooltip> <InfoTooltip>
@ -7,6 +7,20 @@
</InfoTooltip> </InfoTooltip>
{{/if}} {{/if}}
</label> </label>
{{#if subText}}
<p class="has-padding-bottom">
{{subText}}
</p>
{{/if}}
{{/if}}
{{#if (get validationMessages name)}}
<div>
<AlertInline
@type="danger"
@message={{get validationMessages name}}
@paddingTop=true
/>
</div>
{{/if}} {{/if}}
{{#each kvData as |row index|}} {{#each kvData as |row index|}}
<div class="columns is-variable" data-test-kv-row> <div class="columns is-variable" data-test-kv-row>
@ -14,7 +28,21 @@
<Input data-test-kv-key={{true}} @value={{row.name}} placeholder="key" @change={{action "updateRow" row index}} class="input" /> <Input data-test-kv-key={{true}} @value={{row.name}} placeholder="key" @change={{action "updateRow" row index}} class="input" />
</div> </div>
<div class="column"> <div class="column">
<Textarea data-test-kv-value={{true}} @name={{row.name}} @change={{action "updateRow" row index}} @value={{row.value}} @wrap="off" class="input" placeholder="value" @rows={{1}} /> <Textarea
data-test-kv-value={{true}}
@name={{row.name}}
class="input {{if (get validationMessages name) "has-error-border"}}"
@change={{action "updateRow" row index}}
@value={{row.value}}
@wrap="off"
class="input"
placeholder="value"
@rows={{1}}
onkeyup={{action
(action "handleKeyUp" name)
value="target.value"
}}
/>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
{{#if (eq kvData.length (inc index))}} {{#if (eq kvData.length (inc index))}}

View File

@ -0,0 +1,236 @@
{{#if (eq @mode "create")}}
<form class="{{if @showAdvancedMode 'advanced-edit' 'simple-edit'}}" onsubmit={{action "createOrUpdateKey" @mode}}>
<div class="field box is-fullwidth is-sideless is-marginless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @model={{@modelForData}} @errorMessage={{this.error}} />
<label class="is-label" for="kv-key">Path for this secret</label>
<p class="control is-expanded">
<Input
@autocomplete="off"
@spellcheck="false"
data-test-secret-path="true"
@id="kv-key"
class="input {{if (get this.validationMessages 'path') "has-error-border"}}"
@value={{get @modelForData @modelForData.pathAttr}}
{{on "keyup" (perform this.waitForKeyUp "path" value="target.value")}}
/>
</p>
{{#if (get this.validationMessages 'path')}}
<AlertInline
@type="danger"
@message={{get this.validationMessages 'path'}}
@paddingTop=true
@isMarginless=true
/>
{{/if}}
{{#if @modelForData.isFolder}}
<p class="help is-danger">
The secret path may not end in <code>/</code>
</p>
{{/if}}
</div>
{{#if @showAdvancedMode}}
<div class="form-section">
<JsonEditor
@title={{if @isV2 "Version Data" "Secret Data"}}
@value={{this.codemirrorString}}
@valueUpdated={{action "codemirrorUpdated"}}
@onFocusOut={{action "formatJSON"}}>
</JsonEditor>
</div>
{{else}}
<div class="form-section">
<label class="title is-5">
Secret data
</label>
{{#each @secretData as |secret index|}}
<div class="info-table-row">
<div class="column is-one-quarter info-table-row-edit">
<Input
data-test-secret-key={{true}}
@value={{secret.name}}
placeholder="key"
@change={{action "handleChange"}}
class="input"
@autocomplete="off"
@spellcheck="false"
/>
</div>
<div class="column info-table-row-edit">
<MaskedInput
@name={{secret.name}}
@onKeyDown={{action "handleKeyDown"}}
@onChange={{action "handleChange"}}
@value={{secret.value}}
data-test-secret-value="true"
/>
</div>
<div class="column is-narrow info-table-row-edit">
{{#if (eq @secretData.length (inc index))}}
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-secret-add-row="true">
Add
</button>
{{else}}
<button
class="button has-text-grey is-expanded is-icon"
type="button"
{{on "click" (fn this.deleteRow secret.name)}}
aria-label="Delete row"
>
<Icon
@glyph="trash"
@size="l"
class="has-text-grey-light"
/>
</button>
{{/if}}
</div>
</div>
{{#if this.validationMessages.key}}
<AlertInline
@type="danger"
@message={{this.validationMessages.key}}
@paddingTop=true
@isMarginless=true
/>
{{/if}}
{{/each}}
</div>
{{/if}}
{{#if (and @isV2 @canCreateSecretMetadata) }}
<ToggleButton
@class="is-block"
@toggleAttr={{"showMetadata"}}
@toggleTarget={{this}}
@openLabel="Hide secret metadata"
@closedLabel="Show secret metadata"
data-test-show-metadata-toggle
/>
{{#if this.showMetadata}}
<SecretEditMetadata
@model={{@model}}
@mode="create"
@updateValidationErrorCount={{action "updateValidationErrorCount"}}
/>
{{/if}}
{{/if}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{or @buttonDisabled this.validationErrorCount this.error}}
class="button is-primary"
data-test-secret-save=true
>
Save
</button>
</div>
<div class="control">
<SecretLink @mode="list" @secret={{@model.parentKey}} @class="button">
Cancel
</SecretLink>
</div>
</div>
</form>
{{/if}}
{{#if (eq @mode "edit")}}
<form onsubmit={{action "createOrUpdateKey" "edit"}}>
<div class="box is-sideless is-fullwidth is-marginless is-paddingless">
<MessageError @model={{@modelForData}} @errorMessage={{this.error}} />
<NamespaceReminder @mode="edit" @noun="secret" />
{{#if this.isCreateNewVersionFromOldVersion}}
<div class="form-section">
<AlertBanner
@type="warning"
@class="is-marginless"
@message="You are creating a new version based on data from Version {{@model.selectedVersion.version}}. The current version for {{@model.id}} is Version {{@model.currentVersion}}."
/>
</div>
{{/if}}
{{#if @showAdvancedMode}}
<div class="form-section">
<JsonEditor
@title={{if @isV2 "Version Data" "Secret Data"}}
@value={{this.codemirrorString}}
@valueUpdated={{action "codemirrorUpdated"}}
@onFocusOut={{action "formatJSON"}}>
</JsonEditor>
</div>
{{else}}
<div class="form-section">
<label class="title is-5">
{{#if @isV2}}
Version data
{{else}}
Secret data
{{/if}}
</label>
{{#each @secretData as |secret index|}}
<div class="columns is-variable has-no-shadow">
<div class="column is-one-quarter ">
<Input
data-test-secret-key={{true}}
@value={{secret.name}}
placeholder="key"
@change={{action "handleChange"}}
class="input"
@autocomplete="off"
@spellcheck="false"
/>
</div>
<div class="column">
<MaskedInput
@name={{secret.name}}
@onKeyDown={{action "handleKeyDown"}}
@onChange={{action "handleChange"}}
@value={{secret.value}}
data-test-secret-value="true"
/>
</div>
<div class="column is-narrow ">
{{#if (eq @secretData.length (inc index))}}
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-secret-add-row="true">
Add
</button>
{{else}}
<button
class="button has-text-grey is-expanded is-icon"
type="button"
{{action "deleteRow" secret.name}}
aria-label="Delete row"
>
<Icon
@glyph="trash"
@size="l"
class="has-text-grey-light"
/>
</button>
{{/if}}
</div>
</div>
{{/each}}
</div>
{{/if}}
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<div class="control">
<button
data-test-secret-save
type="submit"
disabled={{or @buttonDisabled this.validationErrorCount}}
class="button is-primary"
>
Save
</button>
</div>
<div class="control">
<SecretLink @mode="show" @secret={{@model.id}} @class="button" @queryParams={{query-params version=@modelForData.version}}>
Cancel
</SecretLink>
</div>
</div>
</div>
</form>
{{/if}}

View File

@ -1,5 +1,5 @@
{{#unless @isV2}} {{#unless @isV2}}
{{#if this.canDelete}} {{#if this.canDeleteSecretData}}
<ConfirmAction <ConfirmAction
@buttonClasses="toolbar-link" @buttonClasses="toolbar-link"
@confirmTitle="Delete secret?" @confirmTitle="Delete secret?"
@ -33,7 +33,7 @@
</button> </button>
<div class="toolbar-separator"/> <div class="toolbar-separator"/>
{{else}} {{else}}
{{#if (or this.canDeleteAnyVersion (and this.isLatestVersion this.canDelete))}} {{#if (or this.canDeleteAnyVersion (and this.isLatestVersion this.canDeleteSecretData))}}
<ConfirmAction <ConfirmAction
@buttonClasses="toolbar-link" @buttonClasses="toolbar-link"
@confirmTitle="Delete" @confirmTitle="Delete"
@ -59,7 +59,7 @@
<p class="has-bottom-margin-s"><strong>How would you like to proceed?</strong></p> <p class="has-bottom-margin-s"><strong>How would you like to proceed?</strong></p>
{{#unless @modelForData.destroyed}} {{#unless @modelForData.destroyed}}
{{#unless @modelForData.deleted}} {{#unless @modelForData.deleted}}
{{#if this.canDelete}} {{#if this.canDeleteSecretData}}
<div class="modal-radio-button" data-test-delete-modal="delete-version"> <div class="modal-radio-button" data-test-delete-modal="delete-version">
<RadioButton <RadioButton
@value="delete" @value="delete"

View File

@ -1,115 +0,0 @@
{{#if (and (or @model.isNew @canEditV2Secret) @isV2 (not @model.failedServerRead))}}
<div data-test-metadata-fields class="form-section box is-shadowless is-fullwidth">
<label class="title is-5">
Secret metadata
</label>
{{#each @model.fields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{@model}}
@onKeyUp={{@onKeyUp}}
@validationMessages={{@validationMessages}}
/>
{{/each}}
</div>
{{/if}}
{{#if @showWriteWithoutReadWarning}}
{{#if (and @isV2 @model.failedServerRead)}}
<AlertBanner
@type="warning"
@message="Your policies prevent you from reading metadata for this secret and the current version's data. Creating a new version of the secret with this form will not be able to use the check-and-set mechanism. If this is required on the secret, then you will need access to read the secret's metadata."
@class="is-marginless"
data-test-v2-no-cas-warning
/>
{{else if @isV2}}
<AlertBanner
@type="warning"
@message="Your policies prevent you from reading the current secret version. Saving this form will create a new version of the secret and will utilize the available check-and-set mechanism."
@class="is-marginless"
data-test-v2-write-without-read
/>
{{else}}
<AlertBanner
@type="warning"
@message="Your policies prevent you from reading the current secret data. Saving using this form will overwrite the existing values."
@class="is-marginless"
data-test-v1-write-without-read
/>
{{/if}}
{{/if}}
{{#if @showAdvancedMode}}
<div class="form-section">
<JsonEditor
@title={{if isV2 "Version Data" "Secret Data"}}
@value={{@codemirrorString}}
@valueUpdated={{action @editActions.codemirrorUpdated}}
@onFocusOut={{action @editActions.formatJSON}}>
</JsonEditor>
</div>
{{else}}
<div class="form-section">
<label class="title is-5">
{{#if isV2}}
Version data
{{else}}
Secret data
{{/if}}
</label>
{{#each @secretData as |secret index|}}
<div class="columns is-variable has-no-shadow">
<div class="column is-one-quarter ">
<Input
data-test-secret-key={{true}}
@value={{secret.name}}
placeholder="key"
@change={{action @editActions.handleChange}}
class="input"
@autocomplete="off"
@spellcheck="false"
{{on "keyup" (fn @onKeyUp "key" secret.name)}}
/>
</div>
<div class="column">
<MaskedInput
@name={{secret.name}}
@onKeyDown={{@editActions.handleKeyDown}}
@onChange={{@editActions.handleChange}}
@value={{secret.value}}
data-test-secret-value="true"
/>
</div>
<div class="column is-narrow ">
{{#if (eq @secretData.length (inc index))}}
<button type="button" {{action @editActions.addRow}} class="button is-outlined is-primary" data-test-secret-add-row="true">
Add
</button>
{{else}}
<button
class="button has-text-grey is-expanded is-icon"
type="button"
{{action @editActions.deleteRow secret.name}}
aria-label="Delete row"
>
<Icon
@glyph="trash"
@size="l"
class="has-text-grey-light"
/>
</button>
{{/if}}
</div>
</div>
{{#if @validationMessages.key}}
<AlertInline
@type="danger"
@message={{@validationMessages.key}}
@paddingTop=true
@isMarginless=true
/>
{{/if}}
{{/each}}
</div>
{{/if}}

View File

@ -0,0 +1,56 @@
<form onsubmit={{this.onSaveChanges}}>
<div data-test-metadata-fields class={{if (eq @mode "create") "box has-container is-fullwidth" "form-section is-fullwidth"}}>
<p class="field">
The options below are all version-agnostic; they apply to all versions of this secret. {{if (eq @mode 'create') 'After the secret is created, this can be edited in the Metadata tab.' ''}}
</p>
{{#each @model.fields as |attr|}}
{{#if (eq attr.name "customMetadata")}}
<MessageError @errorMessage={{this.error}} @model={{@model}} />
<FormField
data-test-field
@attr={{attr}}
@model={{@model}}
@onKeyUp={{action "onKeyUp"}}
@validationMessages={{this.validationMessages}}
@mode={{@mode}}
/>
<label class={{if (eq @mode "create") "title has-padding-top is-5" "title has-padding-top is-4"}}>
Additional options
</label>
{{/if}}
{{#unless (eq attr.name "customMetadata")}}
<FormField
data-test-field
@attr={{attr}}
@model={{@model}}
@onKeyUp={{action "onKeyUp"}}
@validationMessages={{this.validationMessages}}
/>
{{/unless}}
{{/each}}
</div>
{{#unless (eq @mode "create")}}
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<div class="control">
<button
type="submit"
disabled={{this.validationErrorCount}}
class="button is-primary"
>
Save
</button>
</div>
<div class="control">
<SecretLink
@secret={{@model.id}}
@class="button"
@mode="show"
>
Cancel
</SecretLink>
</div>
</div>
</div>
{{/unless}}
</form>

View File

@ -0,0 +1,118 @@
<Toolbar>
{{#unless (and (eq @mode 'show') @isWriteWithoutRead)}}
<ToolbarFilters>
<Toggle
@name="json"
@status="success"
@size="small"
@disabled={{and (eq @mode 'show') @secretDataIsAdvanced}}
@checked={{@showAdvancedMode}}
@onChange={{action @editActions.toggleAdvanced}}
>
<span class="has-text-grey">JSON</span>
</Toggle>
</ToolbarFilters>
{{/unless}}
<ToolbarActions>
{{#if (eq @mode 'show')}}
<SecretDeleteMenu
@modelForData={{@modelForData}}
@model={{@model}}
@navToNearestAncestor={{@navToNearestAncestor}}
@isV2={{@isV2}}
@refresh={{action @editActions.refresh}}
/>
{{/if}}
{{#if (and (eq @mode 'show') @canUpdateSecretData)}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq @mode 'show') 'edit' 'show')) as |targetRoute|}}
{{#unless (and @isV2 (or @isWriteWithoutRead @modelForData.destroyed @modelForData.deleted))}}
<BasicDropdown
@class="popup-menu"
@horizontalPosition="auto-right"
@verticalPosition="below"
@onClose={{action "clearWrappedData"}}
as |D|
>
<D.trigger
data-test-popup-menu-trigger="true"
@class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button"
>
Copy
<Chevron @direction="down" @isButton={{true}} />
</D.trigger>
<D.content @class="popup-menu-content is-wide">
<nav class="box menu">
<ul class="menu-list">
<li class="action">
<CopyButton
@class="link link-plain has-text-weight-semibold is-ghost"
@clipboardText={{@codemirrorString}}
@success={{action (set-flash-message "JSON Copied!")}}
data-test-copy-button
>
Copy JSON
</CopyButton>
</li>
<li class="action">
{{#if this.showWrapButton}}
<button
class="link link-plain has-text-weight-semibold is-ghost {{if isWrapping "is-loading"}}"
type="button"
{{on "click" this.handleWrapClick}}
data-test-wrap-button
disabled={{this.isWrapping}}
>
Wrap secret
</button>
{{else}}
<MaskedInput
@class="has-padding"
@displayOnly={{true}}
@allowCopy={{true}}
@value={{this.wrappedData}}
@success={{action "handleCopySuccess"}}
@error={{action "handleCopyError"}}
/>
{{/if}}
</li>
</ul>
</nav>
</D.content>
</BasicDropdown>
{{/unless}}
{{/let}}
{{/if}}
{{#if (and (eq @mode "show") @isV2 (not @model.failedServerRead))}}
<SecretVersionMenu
@version={{@modelForData}}
@onRefresh={{action @editActions.refresh}}
@model={{@model}}
/>
{{/if}}
{{#if (and (eq @mode 'show') @canUpdateSecretData)}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq @mode 'show') 'edit' 'show')) as |targetRoute|}}
{{#if @isV2}}
<ToolbarLink
@params={{array targetRoute @model.id (query-params version=@modelForData.version)}}
@data-test-secret-edit="true"
@replace={{true}}
@type="add"
>
Create new version
</ToolbarLink>
{{else}}
<ToolbarLink
@params={{array targetRoute @model.id}}
@data-test-secret-edit="true"
@replace={{true}}
>
Edit secret
</ToolbarLink>
{{/if}}
{{/let}}
{{/if}}
</ToolbarActions>
</Toolbar>

View File

@ -16,246 +16,57 @@
</h1> </h1>
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
{{!-- tabs for show only --}}
<Toolbar> {{#if (eq mode "show")}}
{{#unless (and (eq mode 'show') isWriteWithoutRead)}} <div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<ToolbarFilters> <nav class="tabs">
<Toggle <ul>
@name="json" <LinkTo @route="vault.cluster.secrets.backend.show" @model={{key.id}} @tagName="li" @activeClass="is-active">
@status="success" <LinkTo @route="vault.cluster.secrets.backend.show">
@size="small" Secret
@disabled={{and (eq mode 'show') secretDataIsAdvanced}} </LinkTo>
@checked={{showAdvancedMode}} </LinkTo>
@onChange={{action "toggleAdvanced"}} {{#if model.canReadMetadata}}
> <LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{key.id}} @tagName="li" @activeClass="is-active" data-test-secret-metadata-tab>
<span class="has-text-grey">JSON</span> <LinkTo @route="vault.cluster.secrets.backend.metadata">
</Toggle> Metadata
</ToolbarFilters> </LinkTo>
{{/unless}} </LinkTo>
<ToolbarActions>
{{#if (eq mode 'show')}}
<SecretDeleteMenu
@modelForData={{this.modelForData}}
@model={{this.model}}
@navToNearestAncestor={{this.navToNearestAncestor}}
@isV2={{isV2}}
@refresh={{action 'refresh'}}
/>
{{/if}} {{/if}}
{{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) as |targetRoute|}}
{{#unless (and isV2 (or isWriteWithoutRead modelForData.destroyed modelForData.deleted))}}
<BasicDropdown
@class="popup-menu"
@horizontalPosition="auto-right"
@verticalPosition="below"
@onClose={{action "clearWrappedData"}}
as |D|
>
<D.trigger
data-test-popup-menu-trigger="true"
@class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button"
>
Copy
<Chevron @direction="down" @isButton={{true}} />
</D.trigger>
<D.content @class="popup-menu-content is-wide">
<nav class="box menu">
<ul class="menu-list">
<li class="action">
<CopyButton
@class="link link-plain has-text-weight-semibold is-ghost"
@clipboardText={{codemirrorString}}
@success={{action (set-flash-message "JSON Copied!")}}
data-test-copy-button
>
Copy JSON
</CopyButton>
</li>
<li class="action">
{{#if showWrapButton}}
<button
class="link link-plain has-text-weight-semibold is-ghost {{if isWrapping "is-loading"}}"
type="button"
{{action "handleWrapClick"}}
data-test-wrap-button
disabled={{isWrapping}}
>
Wrap secret
</button>
{{else}}
<MaskedInput
@class="has-padding"
@displayOnly={{true}}
@allowCopy={{true}}
@value={{wrappedData}}
@success={{action "handleCopySuccess"}}
@error={{action "handleCopyError"}}
/>
{{/if}}
</li>
</ul> </ul>
</nav> </nav>
</D.content> </div>
</BasicDropdown>
{{/unless}}
{{/let}}
{{/if}} {{/if}}
{{#if (and (eq @mode "show") this.isV2 (not @model.failedServerRead))}} <SecretEditToolbar
<SecretVersionMenu @mode={{mode}}
@version={{this.modelForData}} @model={{model}}
@onRefresh={{action 'refresh'}} @isV2={{isV2}}
@model={{this.model}} @isWriteWithoutRead={{isWriteWithoutRead}}
@secretDataIsAdvanced={{secretDataIsAdvanced}}
@showAdvancedMode={{showAdvancedMode}}
@modelForData={{modelForData}}
@navToNearestAncestor={{navToNearestAncestor}}
@canUpdateSecretData={{canUpdateSecretData}}
@codemirrorString={{codemirrorString}}
@editActions={{hash
toggleAdvanced=(action "toggleAdvanced")
refresh=(action "refresh")
}}
/> />
{{/if}}
{{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}} {{#if (or (eq mode "create") (eq mode "edit"))}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) as |targetRoute|}} <SecretCreateOrUpdate
{{#if isV2}} @mode={{mode}}
<ToolbarLink
@params={{array targetRoute model.id (query-params version=this.modelForData.version)}}
@data-test-secret-edit="true"
@replace={{true}}
@type="add"
>
Create new version
</ToolbarLink>
{{else}}
<ToolbarLink
@params={{array targetRoute model.id}}
@data-test-secret-edit="true"
@replace={{true}}
>
Edit secret
</ToolbarLink>
{{/if}}
{{/let}}
{{/if}}
</ToolbarActions>
</Toolbar>
{{#if (eq mode "create")}}
<form class="{{if showAdvancedMode 'advanced-edit' 'simple-edit'}}" onsubmit={{action "createOrUpdateKey" "create"}}>
<div class="field box is-fullwidth is-sideless is-marginless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @model={{modelForData}} @errorMessage={{error}} />
<label class="is-label" for="kv-key">Path for this secret</label>
<p class="control is-expanded">
<Input
@autocomplete="off"
@spellcheck="false"
data-test-secret-path="true"
@id="kv-key"
class="input {{if (get validationMessages 'path') "has-error-border"}}"
@value={{get modelForData modelForData.pathAttr}}
onkeyup={{perform waitForKeyUp "path" value="target.value"}}
/>
</p>
{{#if (get validationMessages 'path')}}
<AlertInline
@type="danger"
@message={{get validationMessages 'path'}}
@paddingTop=true
@isMarginless=true
/>
{{/if}}
{{#if modelForData.isFolder}}
<p class="help is-danger">
The secret path may not end in <code>/</code>
</p>
{{/if}}
</div>
<SecretEditDisplay
@showAdvancedMode={{showAdvancedMode}}
@codemirrorString={{codemirrorString}}
@secretData={{secretData}}
@isV2={{isV2}}
@model={{model}} @model={{model}}
@canEditV2Secret={{canEditV2Secret}}
@editActions={{hash
codemirrorUpdated=(action "codemirrorUpdated")
formatJSON=(action "formatJSON")
handleKeyDown=(action "handleKeyDown")
handleChange=(action "handleChange")
deleteRow=(action "deleteRow")
addRow=(action "addRow")
}}
@onKeyUp={{perform waitForKeyUp}}
@validationMessages={{validationMessages}}
/>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
disabled={{or buttonDisabled validationErrorCount}}
class="button is-primary"
data-test-secret-save=true
>
Save
</button>
</div>
<div class="control">
<SecretLink @mode="list" @secret={{model.parentKey}} @class="button">
Cancel
</SecretLink>
</div>
</div>
</form>
{{else if (eq mode "edit")}}
<form onsubmit={{action "createOrUpdateKey" "update"}}>
<div class="box is-sideless is-fullwidth is-marginless is-paddingless">
<MessageError @model={{modelForData}} @errorMessage={{error}} />
<NamespaceReminder @mode="edit" @noun="secret" />
{{#if (and (not model.failedServerRead) (not model.selectedVersion.failedServerRead) (not-eq model.selectedVersion.version model.currentVersion))}}
<div class="form-section">
<AlertBanner
@type="warning"
@class="is-marginless"
@message="You are creating a new version based on data from Version {{model.selectedVersion.version}}. The current version for {{model.id}} is Version {{model.currentVersion}}."
/>
</div>
{{/if}}
<SecretEditDisplay
@showAdvancedMode={{showAdvancedMode}} @showAdvancedMode={{showAdvancedMode}}
@codemirrorString={{codemirrorString}} @modelForData={{modelForData}}
@secretData={{secretData}} @error={{error}}
@isV2={{isV2}} @isV2={{isV2}}
@canEditV2Secret={{canEditV2Secret}} @secretData={{secretData}}
@showWriteWithoutReadWarning={{isWriteWithoutRead}} @buttonDisabled={{buttonDisabled}}
@model={{model}} @canCreateSecretMetadata={{canCreateSecretMetadata}}
@editActions={{hash
codemirrorUpdated=(action "codemirrorUpdated")
formatJSON=(action "formatJSON")
handleKeyDown=(action "handleKeyDown")
handleChange=(action "handleChange")
deleteRow=(action "deleteRow")
addRow=(action "addRow")
}}
@onKeyUp={{perform waitForKeyUp}}
@validationMessages={{validationMessages}}
/> />
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<div class="control">
<button
data-test-secret-save
type="submit"
disabled={{or buttonDisabled validationErrorCount}}
class="button is-primary"
>
Save
</button>
</div>
<div class="control">
<SecretLink @mode="show" @secret={{model.id}} @class="button" @queryParams={{query-params version=this.modelForData.version}}>
Cancel
</SecretLink>
</div>
</div>
</div>
</form>
{{else if (eq mode "show")}} {{else if (eq mode "show")}}
<SecretFormShow <SecretFormShow
@isV2={{isV2}} @isV2={{isV2}}

View File

@ -45,7 +45,7 @@
</button> </button>
</li> </li>
{{else}} {{else}}
{{#if @item.canRead}} {{#if (or @item.canReadSecretData @item.canRead)}}
<li class="action"> <li class="action">
<SecretLink <SecretLink
@mode="show" @mode="show"
@ -65,7 +65,7 @@
</li> </li>
{{/if}} {{/if}}
{{/if}} {{/if}}
{{#if @item.canEdit}} {{#if (or @item.canEditSecretData @item.canEdit)}}
<li class="action"> <li class="action">
<SecretLink <SecretLink
@mode="edit" @mode="edit"
@ -78,7 +78,7 @@
</SecretLink> </SecretLink>
</li> </li>
{{/if}} {{/if}}
{{#if @item.canDelete}} {{#if (or @item.canDeleteSecretData @item.canDelete)}}
<li class="action"> <li class="action">
<c.Message <c.Message
@id={{@item.id}} @id={{@item.id}}

View File

@ -0,0 +1,21 @@
{{#if model.canUpdateMetadata}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Edit secret metadata
</h1>
</p.levelLeft>
</PageHeader>
<SecretEditMetadata
@model={{model}}
/>
{{else}}
<EmptyState
@title="You do not have permissions to edit metadata"
@message="Ask your administrator if you think you should have access.">
<LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{model.id}} @tagName="button" @class="link">
View Metadata
</LinkTo>
<DocLink @path="/api/secret/kv/kv-v2#create-update-metadata">More here</DocLink>
</EmptyState>
{{/if}}

View File

@ -0,0 +1,73 @@
<PageHeader as |p|>
<p.top>
<KeyValueHeader
@baseKey={{hash id=model.id}}
@path="vault.cluster.secrets.backend.show"
@mode="show"
@showCurrent={{true}}
@root={{backendCrumb}}
/>
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{this.model.id}}
</h1>
</p.levelLeft>
</PageHeader>
{{!-- Tabs --}}
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.secrets.backend.show" @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{this.model.id}}>
Secret
</LinkTo>
</LinkTo>
<LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{this.model.id}} @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.secrets.backend.metadata">
Metadata
</LinkTo>
</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
{{#if this.model.canUpdateMetadata}}
<ToolbarActions>
<ToolbarLink @params={{array 'vault.cluster.secrets.backend.edit-metadata' this.model.id }}>
Edit metadata
</ToolbarLink>
</ToolbarActions>
{{/if}}
</Toolbar>
<div class="form-section">
<label class="title has-padding-top is-5">
Custom metadata
</label>
</div>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each-in this.model.customMetadata as | key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}}
<EmptyState
@title="No custom metadata"
@bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored.">
<LinkTo @route="vault.cluster.secrets.backend.edit-metadata" @model={{this.model.id}}>
Add metadata
</LinkTo>
</EmptyState>
{{/each-in}}
</div>
<div class="form-section">
<label class="title has-padding-top is-5">
Secret Metadata
</label>
</div>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{this.model.maxVersions}} />
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{this.model.casRequired}} />
<InfoTableRow @alwaysRender={{true}} @label="Delete version after" @value={{if (eq this.model.deleteVersionAfter "0s") "Never delete" this.model.deleteVersionAfter}} />
</div>

View File

@ -109,6 +109,12 @@
label=labelString label=labelString
warning=attr.options.warning warning=attr.options.warning
helpText=attr.options.helpText helpText=attr.options.helpText
subText=attr.options.subText
small-label=(if (eq mode "create") true false )
formSection=(if (eq mode "customMetadata") false true )
name=valuePath
onKeyUp=onKeyUp
validationMessages=validationMessages
}} }}
{{else if (eq attr.options.editType "file")}} {{else if (eq attr.options.editType "file")}}
{{!-- File Input --}} {{!-- File Input --}}
@ -305,6 +311,9 @@
{{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}} {{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}}
{{/if}} {{/if}}
</label> </label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
{{/if}}
</div> </div>
{{else if (eq attr.type "object")}} {{else if (eq attr.type "object")}}
{{json-editor {{json-editor

View File

@ -48,6 +48,8 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await listPage.create(); await listPage.create();
await settled(); await settled();
await editPage.toggleMetadata();
await settled();
assert.ok(editPage.hasMetadataFields, 'shows the metadata form'); assert.ok(editPage.hasMetadataFields, 'shows the metadata form');
await editPage.createSecret(path, 'foo', 'bar'); await editPage.createSecret(path, 'foo', 'bar');
await settled(); await settled();
@ -76,19 +78,23 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await settled(); await settled();
await click('[data-test-secret-create="true"]'); await click('[data-test-secret-create="true"]');
await fillIn('[data-test-secret-path="true"]', secretPath); await fillIn('[data-test-secret-path="true"]', secretPath);
await fillIn('[data-test-input="maxVersions"]', maxVersions); await editPage.toggleMetadata();
await click('[data-test-secret-save]');
await settled(); await settled();
await click('[data-test-secret-edit="true"]'); await fillIn('[data-test-input="maxVersions"]', maxVersions);
await settled();
await editPage.save();
await settled();
await editPage.metadataTab();
await settled(); await settled();
// convert to number for IE11 browserstack test // convert to number for IE11 browserstack test
let savedMaxVersions = Number(document.querySelector('[data-test-input="maxVersions"]').value); let savedMaxVersions = Number(document.querySelectorAll('[data-test-value-div]')[0].innerText);
assert.equal( assert.equal(
maxVersions, maxVersions,
savedMaxVersions, savedMaxVersions,
'max_version displays the saved number set when creating the secret' 'max_version displays the saved number set when creating the secret'
); );
}); });
// ARG TOD add test here that adds custom metadata
test('it disables save when validation errors occur', async function(assert) { test('it disables save when validation errors occur', async function(assert) {
let enginePath = `kv-${new Date().getTime()}`; let enginePath = `kv-${new Date().getTime()}`;
@ -105,8 +111,11 @@ module('Acceptance | secrets/secret/create', function(hooks) {
'when duplicate path it shows correct error message' 'when duplicate path it shows correct error message'
); );
await editPage.toggleMetadata();
await settled();
document.querySelector('#maxVersions').value = 'abc'; document.querySelector('#maxVersions').value = 'abc';
await triggerKeyEvent('[data-test-input="maxVersions"]', 'keyup', 65); await triggerKeyEvent('[data-test-input="maxVersions"]', 'keyup', 65);
await settled();
assert assert
.dom('[data-test-input="maxVersions"]') .dom('[data-test-input="maxVersions"]')
.hasClass('has-error-border', 'shows border error on input with error'); .hasClass('has-error-border', 'shows border error on input with error');
@ -577,7 +586,6 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await editPage.visitEdit({ backend, id: 'secret' }); await editPage.visitEdit({ backend, id: 'secret' });
assert.notOk(editPage.hasMetadataFields, 'hides the metadata form'); assert.notOk(editPage.hasMetadataFields, 'hides the metadata form');
assert.ok(editPage.showsNoCASWarning, 'shows no CAS write warning');
await editPage.editSecret('bar', 'baz'); await editPage.editSecret('bar', 'baz');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
@ -596,7 +604,6 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await editPage.visitEdit({ backend, id: 'secret' }); await editPage.visitEdit({ backend, id: 'secret' });
assert.notOk(editPage.hasMetadataFields, 'hides the metadata form'); assert.notOk(editPage.hasMetadataFields, 'hides the metadata form');
assert.ok(editPage.showsV2WriteWarning, 'shows v2 warning');
await editPage.editSecret('bar', 'baz'); await editPage.editSecret('bar', 'baz');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
@ -614,8 +621,6 @@ module('Acceptance | secrets/secret/create', function(hooks) {
assert.ok(showPage.editIsPresent, 'shows the edit button'); assert.ok(showPage.editIsPresent, 'shows the edit button');
await editPage.visitEdit({ backend, id: 'secret' }); await editPage.visitEdit({ backend, id: 'secret' });
assert.ok(editPage.showsV1WriteWarning, 'shows v1 warning');
await editPage.editSecret('bar', 'baz'); await editPage.editSecret('bar', 'baz');
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
}); });

View File

@ -97,6 +97,7 @@ module('Integration | Component | secret edit', function(hooks) {
}); });
await render(hbs`{{secret-edit mode=mode model=model preferAdvancedEdit=true }}`); await render(hbs`{{secret-edit mode=mode model=model preferAdvancedEdit=true }}`);
await settled();
let instance = this.codeMirror.instanceFor(find('[data-test-component=json-editor]').id); let instance = this.codeMirror.instanceFor(find('[data-test-component=json-editor]').id);
instance.setValue(JSON.stringify([{ foo: 'bar' }])); instance.setValue(JSON.stringify([{ foo: 'bar' }]));
await settled(); await settled();

View File

@ -12,10 +12,9 @@ export default create({
visitEdit: visitable('/vault/secrets/:backend/edit/:id'), visitEdit: visitable('/vault/secrets/:backend/edit/:id'),
visitEditRoot: visitable('/vault/secrets/:backend/edit'), visitEditRoot: visitable('/vault/secrets/:backend/edit'),
toggleJSON: clickable('[data-test-toggle-input="json"]'), toggleJSON: clickable('[data-test-toggle-input="json"]'),
toggleMetadata: clickable('[data-test-show-metadata-toggle]'),
metadataTab: clickable('[data-test-secret-metadata-tab]'),
hasMetadataFields: isPresent('[data-test-metadata-fields]'), hasMetadataFields: isPresent('[data-test-metadata-fields]'),
showsNoCASWarning: isPresent('[data-test-v2-no-cas-warning]'),
showsV2WriteWarning: isPresent('[data-test-v2-write-without-read]'),
showsV1WriteWarning: isPresent('[data-test-v1-write-without-read]'),
editor: { editor: {
fillIn: codeFillable('[data-test-component="json-editor"]'), fillIn: codeFillable('[data-test-component="json-editor"]'),
}, },