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:
parent
c99cf35b6a
commit
c013e4a741
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
ui: Add custom metadata to KV secret engine and metadata to config
|
||||
```
|
|
@ -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) {
|
||||
|
|
|
@ -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 { 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 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));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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',
|
||||
|
|
|
@ -9,7 +9,7 @@ import { validator, buildValidations } from 'ember-cp-validations';
|
|||
const Validations = buildValidations({
|
||||
maxVersions: [
|
||||
validator('number', {
|
||||
allowString: false,
|
||||
allowString: true,
|
||||
integer: true,
|
||||
message: 'Maximum versions must be a number.',
|
||||
}),
|
||||
|
@ -31,23 +31,40 @@ export default Model.extend(KeyMixin, Validations, {
|
|||
updatedTime: attr(),
|
||||
currentVersion: attr('number'),
|
||||
oldestVersion: attr('number'),
|
||||
customMetadata: attr('object', {
|
||||
editType: 'kv',
|
||||
subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.',
|
||||
}),
|
||||
maxVersions: attr('number', {
|
||||
defaultValue: 10,
|
||||
label: 'Maximum Number of Versions',
|
||||
label: 'Maximum number of versions',
|
||||
subText:
|
||||
'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.',
|
||||
}),
|
||||
casRequired: attr('boolean', {
|
||||
defaultValue: false,
|
||||
label: 'Require Check and Set',
|
||||
helpText:
|
||||
'Writes will only be allowed if the key’s current version matches the version specified in the cas parameter',
|
||||
subText:
|
||||
'Writes will only be allowed if the key’s current version matches the version specified in the cas parameter.',
|
||||
}),
|
||||
deleteVersionAfter: attr({
|
||||
defaultValue: 0,
|
||||
editType: 'ttl',
|
||||
label: 'Automate secret deletion',
|
||||
helperTextDisabled: 'A secret’s version must be manually deleted.',
|
||||
helperTextEnabled: 'Delete all new versions of this secret after',
|
||||
}),
|
||||
fields: computed(function() {
|
||||
return expandAttributeMeta(this, ['maxVersions', 'casRequired']);
|
||||
return expandAttributeMeta(this, ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']);
|
||||
}),
|
||||
versionPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'),
|
||||
secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'),
|
||||
secretDataPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'),
|
||||
secretMetadataPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'),
|
||||
|
||||
canEdit: alias('versionPath.canUpdate'),
|
||||
canDelete: alias('secretPath.canDelete'),
|
||||
canRead: alias('secretPath.canRead'),
|
||||
canListMetadata: alias('secretMetadataPath.canList'),
|
||||
canReadMetadata: alias('secretMetadataPath.canRead'),
|
||||
canUpdateMetadata: alias('secretMetadataPath.canUpdate'),
|
||||
|
||||
canReadSecretData: alias('secretDataPath.canRead'),
|
||||
canEditSecretData: alias('secretDataPath.canUpdate'),
|
||||
canDeleteSecretData: alias('secretDataPath.canDelete'),
|
||||
});
|
||||
|
|
|
@ -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' });
|
||||
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Metadata from './metadata';
|
||||
|
||||
export default class EditMetadataRoute extends Metadata {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -58,6 +58,10 @@ label {
|
|||
margin-left: $size-10;
|
||||
}
|
||||
|
||||
.b-checkbox > .sub-text {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.help {
|
||||
&.is-danger {
|
||||
font-weight: $weight-bold;
|
||||
|
|
|
@ -1,12 +1,26 @@
|
|||
{{#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}}
|
||||
{{#if helpText}}
|
||||
<InfoTooltip>
|
||||
{{helpText}}
|
||||
{{helpText}}
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</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}}
|
||||
{{#each kvData as |row index|}}
|
||||
<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" />
|
||||
</div>
|
||||
<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 class="column is-narrow">
|
||||
{{#if (eq kvData.length (inc index))}}
|
||||
|
|
|
@ -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}}
|
|
@ -1,5 +1,5 @@
|
|||
{{#unless @isV2}}
|
||||
{{#if this.canDelete}}
|
||||
{{#if this.canDeleteSecretData}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@confirmTitle="Delete secret?"
|
||||
|
@ -24,16 +24,16 @@
|
|||
{{#if (and (not @modelForData.deleted) (not @modelForData.destroyed)) }}
|
||||
{{#if (or this.canDestroyVersion this.canDestroyAllVersions)}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on "click" (fn (mut this.showDeleteModal false))}}
|
||||
data-test-delete-open-modal
|
||||
>
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on "click" (fn (mut this.showDeleteModal false))}}
|
||||
data-test-delete-open-modal
|
||||
>
|
||||
{{if (and (not @modelForData.deleted) (not @modelForData.destroyed)) "Delete" "Destroy"}}
|
||||
</button>
|
||||
<div class="toolbar-separator"/>
|
||||
{{else}}
|
||||
{{#if (or this.canDeleteAnyVersion (and this.isLatestVersion this.canDelete))}}
|
||||
{{#if (or this.canDeleteAnyVersion (and this.isLatestVersion this.canDeleteSecretData))}}
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@confirmTitle="Delete"
|
||||
|
@ -59,7 +59,7 @@
|
|||
<p class="has-bottom-margin-s"><strong>How would you like to proceed?</strong></p>
|
||||
{{#unless @modelForData.destroyed}}
|
||||
{{#unless @modelForData.deleted}}
|
||||
{{#if this.canDelete}}
|
||||
{{#if this.canDeleteSecretData}}
|
||||
<div class="modal-radio-button" data-test-delete-modal="delete-version">
|
||||
<RadioButton
|
||||
@value="delete"
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
|
@ -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>
|
|
@ -16,246 +16,57 @@
|
|||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<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 "toggleAdvanced"}}
|
||||
>
|
||||
<span class="has-text-grey">JSON</span>
|
||||
</Toggle>
|
||||
</ToolbarFilters>
|
||||
{{/unless}}
|
||||
<ToolbarActions>
|
||||
{{#if (eq mode 'show')}}
|
||||
<SecretDeleteMenu
|
||||
@modelForData={{this.modelForData}}
|
||||
@model={{this.model}}
|
||||
@navToNearestAncestor={{this.navToNearestAncestor}}
|
||||
@isV2={{isV2}}
|
||||
@refresh={{action 'refresh'}}
|
||||
/>
|
||||
{{/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>
|
||||
</nav>
|
||||
</D.content>
|
||||
</BasicDropdown>
|
||||
{{/unless}}
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (and (eq @mode "show") this.isV2 (not @model.failedServerRead))}}
|
||||
<SecretVersionMenu
|
||||
@version={{this.modelForData}}
|
||||
@onRefresh={{action 'refresh'}}
|
||||
@model={{this.model}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}}
|
||||
{{#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=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>
|
||||
{{!-- tabs for show only --}}
|
||||
{{#if (eq mode "show")}}
|
||||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{key.id}} @tagName="li" @activeClass="is-active">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show">
|
||||
Secret
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
{{#if model.canReadMetadata}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.metadata" @model={{key.id}} @tagName="li" @activeClass="is-active" data-test-secret-metadata-tab>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.metadata">
|
||||
Metadata
|
||||
</LinkTo>
|
||||
</LinkTo>
|
||||
{{/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}}
|
||||
@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}}
|
||||
@codemirrorString={{codemirrorString}}
|
||||
@secretData={{secretData}}
|
||||
@isV2={{isV2}}
|
||||
@canEditV2Secret={{canEditV2Secret}}
|
||||
@showWriteWithoutReadWarning={{isWriteWithoutRead}}
|
||||
@model={{model}}
|
||||
@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>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<SecretEditToolbar
|
||||
@mode={{mode}}
|
||||
@model={{model}}
|
||||
@isV2={{isV2}}
|
||||
@isWriteWithoutRead={{isWriteWithoutRead}}
|
||||
@secretDataIsAdvanced={{secretDataIsAdvanced}}
|
||||
@showAdvancedMode={{showAdvancedMode}}
|
||||
@modelForData={{modelForData}}
|
||||
@navToNearestAncestor={{navToNearestAncestor}}
|
||||
@canUpdateSecretData={{canUpdateSecretData}}
|
||||
@codemirrorString={{codemirrorString}}
|
||||
@editActions={{hash
|
||||
toggleAdvanced=(action "toggleAdvanced")
|
||||
refresh=(action "refresh")
|
||||
}}
|
||||
/>
|
||||
|
||||
{{#if (or (eq mode "create") (eq mode "edit"))}}
|
||||
<SecretCreateOrUpdate
|
||||
@mode={{mode}}
|
||||
@model={{model}}
|
||||
@showAdvancedMode={{showAdvancedMode}}
|
||||
@modelForData={{modelForData}}
|
||||
@error={{error}}
|
||||
@isV2={{isV2}}
|
||||
@secretData={{secretData}}
|
||||
@buttonDisabled={{buttonDisabled}}
|
||||
@canCreateSecretMetadata={{canCreateSecretMetadata}}
|
||||
/>
|
||||
{{else if (eq mode "show")}}
|
||||
<SecretFormShow
|
||||
@isV2={{isV2}}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
</button>
|
||||
</li>
|
||||
{{else}}
|
||||
{{#if @item.canRead}}
|
||||
{{#if (or @item.canReadSecretData @item.canRead)}}
|
||||
<li class="action">
|
||||
<SecretLink
|
||||
@mode="show"
|
||||
|
@ -65,7 +65,7 @@
|
|||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if @item.canEdit}}
|
||||
{{#if (or @item.canEditSecretData @item.canEdit)}}
|
||||
<li class="action">
|
||||
<SecretLink
|
||||
@mode="edit"
|
||||
|
@ -78,7 +78,7 @@
|
|||
</SecretLink>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if @item.canDelete}}
|
||||
{{#if (or @item.canDeleteSecretData @item.canDelete)}}
|
||||
<li class="action">
|
||||
<c.Message
|
||||
@id={{@item.id}}
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
|
@ -18,21 +18,21 @@
|
|||
)
|
||||
)
|
||||
}}
|
||||
{{#unless (eq attr.type "object")}}
|
||||
{{#unless (eq attr.type "object")}}
|
||||
<label for="{{attr.name}}" class="is-label">
|
||||
{{labelString}}
|
||||
{{#if attr.options.helpText}}
|
||||
{{#info-tooltip}}
|
||||
<span data-test-help-text>
|
||||
{{attr.options.helpText}}
|
||||
</span>
|
||||
{{/info-tooltip}}
|
||||
{{labelString}}
|
||||
{{#if attr.options.helpText}}
|
||||
{{#info-tooltip}}
|
||||
<span data-test-help-text>
|
||||
{{attr.options.helpText}}
|
||||
</span>
|
||||
{{/info-tooltip}}
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if attr.options.subText}}
|
||||
<p class="sub-text">{{attr.options.subText}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</p>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if attr.options.subText}}
|
||||
<p class="sub-text">{{attr.options.subText}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</p>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
{{#if attr.options.possibleValues}}
|
||||
<div class="control is-expanded">
|
||||
|
@ -109,6 +109,12 @@
|
|||
label=labelString
|
||||
warning=attr.options.warning
|
||||
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")}}
|
||||
{{!-- File Input --}}
|
||||
|
@ -305,6 +311,9 @@
|
|||
{{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}}
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if attr.options.subText}}
|
||||
<p class="sub-text">{{attr.options.subText}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else if (eq attr.type "object")}}
|
||||
{{json-editor
|
||||
|
|
|
@ -48,6 +48,8 @@ module('Acceptance | secrets/secret/create', function(hooks) {
|
|||
|
||||
await listPage.create();
|
||||
await settled();
|
||||
await editPage.toggleMetadata();
|
||||
await settled();
|
||||
assert.ok(editPage.hasMetadataFields, 'shows the metadata form');
|
||||
await editPage.createSecret(path, 'foo', 'bar');
|
||||
await settled();
|
||||
|
@ -76,19 +78,23 @@ module('Acceptance | secrets/secret/create', function(hooks) {
|
|||
await settled();
|
||||
await click('[data-test-secret-create="true"]');
|
||||
await fillIn('[data-test-secret-path="true"]', secretPath);
|
||||
await fillIn('[data-test-input="maxVersions"]', maxVersions);
|
||||
await click('[data-test-secret-save]');
|
||||
await editPage.toggleMetadata();
|
||||
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();
|
||||
// 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(
|
||||
maxVersions,
|
||||
savedMaxVersions,
|
||||
'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) {
|
||||
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'
|
||||
);
|
||||
|
||||
await editPage.toggleMetadata();
|
||||
await settled();
|
||||
document.querySelector('#maxVersions').value = 'abc';
|
||||
await triggerKeyEvent('[data-test-input="maxVersions"]', 'keyup', 65);
|
||||
await settled();
|
||||
assert
|
||||
.dom('[data-test-input="maxVersions"]')
|
||||
.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' });
|
||||
assert.notOk(editPage.hasMetadataFields, 'hides the metadata form');
|
||||
assert.ok(editPage.showsNoCASWarning, 'shows no CAS write warning');
|
||||
|
||||
await editPage.editSecret('bar', 'baz');
|
||||
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' });
|
||||
assert.notOk(editPage.hasMetadataFields, 'hides the metadata form');
|
||||
assert.ok(editPage.showsV2WriteWarning, 'shows v2 warning');
|
||||
|
||||
await editPage.editSecret('bar', 'baz');
|
||||
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');
|
||||
|
||||
await editPage.visitEdit({ backend, id: 'secret' });
|
||||
assert.ok(editPage.showsV1WriteWarning, 'shows v1 warning');
|
||||
|
||||
await editPage.editSecret('bar', 'baz');
|
||||
assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.show', 'redirects to the show page');
|
||||
});
|
||||
|
|
|
@ -97,6 +97,7 @@ module('Integration | Component | secret edit', function(hooks) {
|
|||
});
|
||||
|
||||
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);
|
||||
instance.setValue(JSON.stringify([{ foo: 'bar' }]));
|
||||
await settled();
|
||||
|
|
|
@ -12,10 +12,9 @@ export default create({
|
|||
visitEdit: visitable('/vault/secrets/:backend/edit/:id'),
|
||||
visitEditRoot: visitable('/vault/secrets/:backend/edit'),
|
||||
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]'),
|
||||
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: {
|
||||
fillIn: codeFillable('[data-test-component="json-editor"]'),
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue