mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-30 18:17:55 +00:00
Create KV V2 Ember Engine (#22426)
This commit is contained in:
13
ui/app/adapters/kv/config.js
Normal file
13
ui/app/adapters/kv/config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from '../application';
|
||||
export default class KvConfigAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
urlForFindRecord(id) {
|
||||
return `${this.buildURL()}/${id}/config`;
|
||||
}
|
||||
}
|
||||
136
ui/app/adapters/kv/data.js
Normal file
136
ui/app/adapters/kv/data.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from '../application';
|
||||
import { kvDataPath, kvDeletePath, kvDestroyPath, kvUndeletePath } from 'vault/utils/kv-path';
|
||||
import { assert } from '@ember/debug';
|
||||
import ControlGroupError from 'vault/lib/control-group-error';
|
||||
|
||||
export default class KvDataAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
_url(fullPath) {
|
||||
return `${this.buildURL()}/${fullPath}`;
|
||||
}
|
||||
|
||||
createRecord(store, type, snapshot) {
|
||||
const { backend, path } = snapshot.record;
|
||||
const url = this._url(kvDataPath(backend, path));
|
||||
|
||||
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then((res) => {
|
||||
return {
|
||||
data: {
|
||||
id: kvDataPath(backend, path, res.data.version),
|
||||
backend,
|
||||
path,
|
||||
...res.data,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fetchWrapInfo(query) {
|
||||
const { backend, path, version, wrapTTL } = query;
|
||||
const id = kvDataPath(backend, path, version);
|
||||
return this.ajax(this._url(id), 'GET', { wrapTTL }).then((resp) => resp.wrap_info);
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { backend, path, version } = query;
|
||||
// ID is the full path for the data (including version)
|
||||
let id = kvDataPath(backend, path, version);
|
||||
return this.ajax(this._url(id), 'GET')
|
||||
.then((resp) => {
|
||||
// if no version is queried, add version from response to ID
|
||||
// otherwise duplicate ember data models will exist in store
|
||||
// (one with an ID that includes the version and one without)
|
||||
if (!version) {
|
||||
id = kvDataPath(backend, path, resp.data.metadata.version);
|
||||
}
|
||||
return {
|
||||
...resp,
|
||||
data: {
|
||||
id,
|
||||
backend,
|
||||
path,
|
||||
...resp.data,
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((errorOrResponse) => {
|
||||
const baseResponse = { id, backend, path, version };
|
||||
const errorCode = errorOrResponse.httpStatus;
|
||||
// if it's a legitimate error - throw it!
|
||||
if (errorOrResponse instanceof ControlGroupError) {
|
||||
throw errorOrResponse;
|
||||
}
|
||||
|
||||
if (errorCode === 403) {
|
||||
return {
|
||||
data: {
|
||||
...baseResponse,
|
||||
fail_read_error_code: errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (errorOrResponse.data) {
|
||||
// in the case of a deleted/destroyed secret the API returns a 404 because { data: null }
|
||||
// however, there could be a metadata block with important information like deletion_time
|
||||
// handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists.
|
||||
// we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught"
|
||||
// the metadata that sneakily tried to hide from us
|
||||
return {
|
||||
...errorOrResponse,
|
||||
data: {
|
||||
...baseResponse,
|
||||
...errorOrResponse.data, // includes the { metadata } key we want
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// If we get here, it's probably a 404 because it doesn't exist
|
||||
throw errorOrResponse;
|
||||
});
|
||||
}
|
||||
|
||||
/* Five types of delete operations (the 5th operation is on the kv/metadata adapter) */
|
||||
deleteRecord(store, type, snapshot) {
|
||||
const { backend, path } = snapshot.record;
|
||||
const { deleteType, deleteVersions } = snapshot.adapterOptions;
|
||||
|
||||
if (!backend || !path) {
|
||||
throw new Error('The request to delete or undelete is missing required attributes.');
|
||||
}
|
||||
|
||||
switch (deleteType) {
|
||||
case 'delete-latest-version':
|
||||
return this.ajax(this._url(kvDataPath(backend, path)), 'DELETE');
|
||||
case 'delete-version':
|
||||
return this.ajax(this._url(kvDeletePath(backend, path)), 'POST', {
|
||||
data: { versions: deleteVersions },
|
||||
});
|
||||
case 'destroy':
|
||||
return this.ajax(this._url(kvDestroyPath(backend, path)), 'PUT', {
|
||||
data: { versions: deleteVersions },
|
||||
});
|
||||
case 'undelete':
|
||||
return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', {
|
||||
data: { versions: deleteVersions },
|
||||
});
|
||||
default:
|
||||
assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.');
|
||||
}
|
||||
}
|
||||
|
||||
handleResponse(status, headers, payload, requestData) {
|
||||
// after deleting a secret version, data is null and the API returns a 404
|
||||
// but there could be relevant metadata
|
||||
if (status === 404 && payload.data?.metadata) {
|
||||
return super.handleResponse(200, headers, payload, requestData);
|
||||
}
|
||||
return super.handleResponse(...arguments);
|
||||
}
|
||||
}
|
||||
77
ui/app/adapters/kv/metadata.js
Normal file
77
ui/app/adapters/kv/metadata.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from '../application';
|
||||
import { kvMetadataPath } from 'vault/utils/kv-path';
|
||||
|
||||
export default class KvMetadataAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
_url(fullPath) {
|
||||
return `${this.buildURL()}/${fullPath}`;
|
||||
}
|
||||
|
||||
createRecord(store, type, snapshot) {
|
||||
const { backend, path } = snapshot.record;
|
||||
const id = kvMetadataPath(backend, path);
|
||||
const url = this._url(id);
|
||||
const data = this.serialize(snapshot);
|
||||
return this.ajax(url, 'POST', { data }).then(() => {
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
updateRecord(store, type, snapshot) {
|
||||
const { backend, path } = snapshot.record;
|
||||
const id = kvMetadataPath(backend, path);
|
||||
const url = this._url(id);
|
||||
const data = this.serialize(snapshot);
|
||||
return this.ajax(url, 'POST', { data }).then(() => {
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
query(store, type, query) {
|
||||
const { backend, pathToSecret } = query;
|
||||
// example of pathToSecret: beep/boop/
|
||||
return this.ajax(this._url(kvMetadataPath(backend, pathToSecret)), 'GET', {
|
||||
data: { list: true },
|
||||
}).then((resp) => {
|
||||
resp.backend = backend;
|
||||
resp.path = pathToSecret;
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const { backend, path } = query;
|
||||
// ID is the full path for the metadata
|
||||
const id = kvMetadataPath(backend, path);
|
||||
return this.ajax(this._url(id), 'GET').then((resp) => {
|
||||
return {
|
||||
id,
|
||||
...resp,
|
||||
data: {
|
||||
backend,
|
||||
path,
|
||||
...resp.data,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deleteRecord(store, type, snapshot) {
|
||||
const { backend, path, fullSecretPath } = snapshot.record;
|
||||
// fullSecretPath is used when deleting from the LIST view and is defined via the serializer
|
||||
// path is used when deleting from the metadata details view.
|
||||
return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE');
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export default ApplicationAdapter.extend({
|
||||
let mountModel, configModel;
|
||||
try {
|
||||
mountModel = await this.ajax(this.internalURL(query.path), 'GET');
|
||||
// TODO kv engine cleanup - this logic can be removed when KV exists in separate ember engine
|
||||
// if kv2 then add the config data to the mountModel
|
||||
// version comes in as a string
|
||||
if (mountModel?.data?.type === 'kv' && mountModel?.data?.options?.version === '2') {
|
||||
|
||||
@@ -52,6 +52,14 @@ export default class App extends Application {
|
||||
},
|
||||
},
|
||||
},
|
||||
kv: {
|
||||
dependencies: {
|
||||
services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'],
|
||||
externalRoutes: {
|
||||
secrets: 'vault.cluster.secrets.backends',
|
||||
},
|
||||
},
|
||||
},
|
||||
pki: {
|
||||
dependencies: {
|
||||
services: [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import Component from '@ember/component';
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
|
||||
export default Component.extend({
|
||||
onExecuteCommand() {},
|
||||
|
||||
@@ -60,6 +60,7 @@ export default class GetCredentialsCard extends Component {
|
||||
if (role) {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.credentials', role);
|
||||
}
|
||||
// TODO kv engine cleanup. Should be able to remove this component and replace with overview card for the role usage. KV engine has switched to overview card.
|
||||
if (secret) {
|
||||
if (secret.endsWith('/')) {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.list', secret);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { task, waitForEvent } from 'ember-concurrency';
|
||||
import Component from '@ember/component';
|
||||
import { set } from '@ember/object';
|
||||
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
|
||||
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
|
||||
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
import Component from '@glimmer/component';
|
||||
import ControlGroupError from 'vault/lib/control-group-error';
|
||||
import Ember from 'ember';
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
import { action, set } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
* @showAdvancedMode={{showAdvancedMode}}
|
||||
* @modelForData={{this.modelForData}}
|
||||
* @canUpdateSecretData={{canUpdateSecretData}}
|
||||
* @codemirrorString={{codemirrorString}}
|
||||
* @wrappedData={{wrappedData}}
|
||||
* @editActions={{hash
|
||||
toggleAdvanced=(action "toggleAdvanced")
|
||||
refresh=(action "refresh")
|
||||
@@ -32,80 +30,45 @@
|
||||
* @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 {boolean} showAdvancedMode - used for JSON toggle
|
||||
* @param {object} modelForData - a modified version of the model with secret data
|
||||
* @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
|
||||
*/
|
||||
/* eslint ember/no-computed-properties-in-native-classes: 'warn' */
|
||||
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';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
|
||||
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');
|
||||
}
|
||||
@task
|
||||
@waitFor
|
||||
*wrapSecret() {
|
||||
const { id } = this.args.modelForData;
|
||||
const { backend } = this.args.model;
|
||||
const wrapTTL = { wrapTTL: 1800 };
|
||||
|
||||
@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;
|
||||
});
|
||||
try {
|
||||
const resp = yield this.args.isV2
|
||||
? this.store.adapterFor('secret-v2-version').queryRecord(id, wrapTTL)
|
||||
: this.store.adapterFor('secret').queryRecord(null, null, { backend, id, ...wrapTTL });
|
||||
this.wrappedData = resp.wrap_info.token;
|
||||
this.flashMessages.success('Secret successfully wrapped!');
|
||||
} catch (e) {
|
||||
this.flashMessages.danger('Could not wrap secret.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { task, waitForEvent } from 'ember-concurrency';
|
||||
import { set } from '@ember/object';
|
||||
|
||||
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
|
||||
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
|
||||
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
import AdapterError from '@ember-data/adapter/error';
|
||||
import { parse } from 'shell-quote';
|
||||
|
||||
|
||||
31
ui/app/models/kv/config.js
Normal file
31
ui/app/models/kv/config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { duration } from 'core/helpers/format-duration';
|
||||
|
||||
// This model is used only for display only - configuration happens via secret-engine model when an engine is mounted
|
||||
@withFormFields(['casRequired', 'deleteVersionAfter', 'maxVersions'])
|
||||
export default class KvConfigModel extends Model {
|
||||
@attr backend;
|
||||
@attr('number', { label: 'Maximum number of versions' }) maxVersions;
|
||||
|
||||
@attr('boolean', { label: 'Require check and set' }) casRequired;
|
||||
|
||||
@attr({ label: 'Automate secret deletion' }) deleteVersionAfter;
|
||||
|
||||
@lazyCapabilities(apiPath`${'backend'}/config`, 'backend') configPath;
|
||||
|
||||
get canRead() {
|
||||
return this.configPath.get('canRead') !== false;
|
||||
}
|
||||
|
||||
// used in template to render using this model instead of secret-engine (where these attrs also exist)
|
||||
get displayFields() {
|
||||
return ['casRequired', 'deleteVersionAfter', 'maxVersions'];
|
||||
}
|
||||
|
||||
get displayDeleteTtl() {
|
||||
if (this.deleteVersionAfter === '0s') return 'Never delete';
|
||||
return duration([this.deleteVersionAfter]);
|
||||
}
|
||||
}
|
||||
113
ui/app/models/kv/data.js
Normal file
113
ui/app/models/kv/data.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
|
||||
/* sample response
|
||||
{
|
||||
"data": {
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"metadata": {
|
||||
"created_time": "2018-03-22T02:24:06.945319214Z",
|
||||
"custom_metadata": {
|
||||
"owner": "jdoe",
|
||||
"mission_critical": "false"
|
||||
},
|
||||
"deletion_time": "",
|
||||
"destroyed": false,
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
const validations = {
|
||||
path: [
|
||||
{ type: 'presence', message: `Path can't be blank.` },
|
||||
{ type: 'endsInSlash', message: `Path can't end in forward slash '/'.` },
|
||||
{
|
||||
type: 'containsWhiteSpace',
|
||||
message:
|
||||
"Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.",
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
secretData: [
|
||||
{
|
||||
validator: (model) =>
|
||||
model.secretData !== undefined && typeof model.secretData !== 'object' ? false : true,
|
||||
message: 'Vault expects data to be formatted as an JSON object.',
|
||||
},
|
||||
],
|
||||
};
|
||||
@withModelValidations(validations)
|
||||
@withFormFields()
|
||||
export default class KvSecretDataModel extends Model {
|
||||
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord.
|
||||
@attr('string', { label: 'Path for this secret' }) path;
|
||||
@attr('object') secretData; // { key: value } data of the secret version
|
||||
|
||||
// Params returned on the GET response.
|
||||
@attr('string') createdTime;
|
||||
@attr('object') customMetadata;
|
||||
@attr('string') deletionTime;
|
||||
@attr('boolean') destroyed;
|
||||
@attr('number') version;
|
||||
// Set in adapter if read failed
|
||||
@attr('number') failReadErrorCode;
|
||||
|
||||
// if creating a new version this value is set in the edit route's
|
||||
// model hook from metadata or secret version, pending permissions
|
||||
// if the value is not a number, don't send options.cas on payload
|
||||
@attr('number')
|
||||
casVersion;
|
||||
|
||||
get state() {
|
||||
if (this.destroyed) return 'destroyed';
|
||||
if (this.deletionTime) return 'deleted';
|
||||
if (this.createdTime) return 'created';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Permissions
|
||||
@lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath;
|
||||
|
||||
get canDeleteLatestVersion() {
|
||||
return this.dataPath.get('canDelete') !== false;
|
||||
}
|
||||
get canDeleteVersion() {
|
||||
return this.deletePath.get('canUpdate') !== false;
|
||||
}
|
||||
get canUndelete() {
|
||||
return this.undeletePath.get('canUpdate') !== false;
|
||||
}
|
||||
get canDestroyVersion() {
|
||||
return this.destroyPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canEditData() {
|
||||
return this.dataPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canReadData() {
|
||||
return this.dataPath.get('canRead') !== false;
|
||||
}
|
||||
get canReadMetadata() {
|
||||
return this.metadataPath.get('canRead') !== false;
|
||||
}
|
||||
get canUpdateMetadata() {
|
||||
return this.metadataPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canListMetadata() {
|
||||
return this.metadataPath.get('canList') !== false;
|
||||
}
|
||||
}
|
||||
106
ui/app/models/kv/metadata.js
Normal file
106
ui/app/models/kv/metadata.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Model, { attr } from '@ember-data/model';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import { keyIsFolder } from 'core/utils/key-utils';
|
||||
|
||||
const validations = {
|
||||
maxVersions: [
|
||||
{ type: 'number', message: 'Maximum versions must be a number.' },
|
||||
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
|
||||
],
|
||||
};
|
||||
const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter'];
|
||||
|
||||
@withModelValidations(validations)
|
||||
@withFormFields(formFieldProps)
|
||||
export default class KvSecretMetadataModel extends Model {
|
||||
@attr('string') backend;
|
||||
@attr('string') path;
|
||||
@attr('string') fullSecretPath;
|
||||
|
||||
@attr('number', {
|
||||
defaultValue: 0,
|
||||
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.',
|
||||
})
|
||||
maxVersions;
|
||||
|
||||
@attr('boolean', {
|
||||
defaultValue: false,
|
||||
label: 'Require Check and Set',
|
||||
subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`,
|
||||
})
|
||||
casRequired;
|
||||
|
||||
@attr('string', {
|
||||
defaultValue: '0s',
|
||||
editType: 'ttl',
|
||||
label: 'Automate secret deletion',
|
||||
helperTextDisabled: `A secret's version must be manually deleted.`,
|
||||
helperTextEnabled: 'Delete all new versions of this secret after.',
|
||||
})
|
||||
deleteVersionAfter;
|
||||
|
||||
@attr('object', {
|
||||
editType: 'kv',
|
||||
subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.',
|
||||
})
|
||||
customMetadata;
|
||||
|
||||
// Additional Params only returned on the GET response.
|
||||
@attr('string') createdTime;
|
||||
@attr('number') currentVersion;
|
||||
@attr('number') oldestVersion;
|
||||
@attr('string') updatedTime;
|
||||
@attr('object') versions;
|
||||
|
||||
// used for KV list and list-directory view
|
||||
get pathIsDirectory() {
|
||||
// ex: beep/
|
||||
return keyIsFolder(this.path);
|
||||
}
|
||||
|
||||
// turns version object into an array for version dropdown menu
|
||||
get sortedVersions() {
|
||||
const array = [];
|
||||
for (const key in this.versions) {
|
||||
array.push({ version: key, ...this.versions[key] });
|
||||
}
|
||||
// version keys are in order created with 1 being the oldest, we want newest first
|
||||
return array.reverse();
|
||||
}
|
||||
|
||||
// helps in long logic statements for state of a currentVersion
|
||||
get currentSecret() {
|
||||
const data = this.versions[this.currentVersion];
|
||||
const state = data.destroyed ? 'destroyed' : data.deletion_time ? 'deleted' : 'created';
|
||||
return {
|
||||
state,
|
||||
isDeactivated: state !== 'created',
|
||||
};
|
||||
}
|
||||
|
||||
// permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups.
|
||||
@lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath;
|
||||
@lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath;
|
||||
|
||||
get canDeleteMetadata() {
|
||||
return this.metadataPath.get('canDelete') !== false;
|
||||
}
|
||||
get canReadMetadata() {
|
||||
return this.metadataPath.get('canRead') !== false;
|
||||
}
|
||||
get canUpdateMetadata() {
|
||||
return this.metadataPath.get('canUpdate') !== false;
|
||||
}
|
||||
get canCreateVersionData() {
|
||||
return this.dataPath.get('canUpdate') !== false;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ Router.map(function () {
|
||||
this.route('backend', { path: '/:backend' }, function () {
|
||||
this.mount('kmip');
|
||||
this.mount('kubernetes');
|
||||
this.mount('kv');
|
||||
this.mount('pki');
|
||||
this.route('index', { path: '/' });
|
||||
this.route('configuration');
|
||||
|
||||
@@ -10,6 +10,7 @@ export default Route.extend({
|
||||
store: service(),
|
||||
async model() {
|
||||
const backend = this.modelFor('vault.cluster.secrets.backend');
|
||||
// TODO kv engine cleanup - this can be removed when KV has fully moved to separate ember engine and list view config details menu is refactored
|
||||
if (backend.isV2KV) {
|
||||
const canRead = await this.store
|
||||
.findRecord('capabilities', `${backend.id}/config`)
|
||||
|
||||
@@ -320,7 +320,7 @@ export default Route.extend(UnloadModelRoute, {
|
||||
if (!model.hasDirtyAttributes || model.isDeleted) {
|
||||
return true;
|
||||
}
|
||||
// TODO: below is KV v2 logic, remove with engine work
|
||||
// TODO kv engine cleanup: below is KV v2 logic, remove with engine work
|
||||
const version = model.get('selectedVersion');
|
||||
const changed = model.changedAttributes();
|
||||
const changedKeys = Object.keys(changed);
|
||||
|
||||
45
ui/app/serializers/kv/data.js
Normal file
45
ui/app/serializers/kv/data.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import ApplicationSerializer from '../application';
|
||||
|
||||
export default class KvDataSerializer extends ApplicationSerializer {
|
||||
serialize(snapshot) {
|
||||
const { secretData, casVersion } = snapshot.record;
|
||||
if (typeof casVersion === 'number') {
|
||||
/* if this is a number it is set by one of the following:
|
||||
A) user is creating initial version of a secret
|
||||
-> 0 : default value set in route
|
||||
B) user is creating a new version of a secret:
|
||||
-> metadata.current_version : has metadata read permissions (data permissions are irrelevant)
|
||||
-> secret.version : has data read permissions. without metadata read access a user is unable to navigate,
|
||||
to older secret versions so we assume creation is from the latest version */
|
||||
return { data: secretData, options: { cas: casVersion } };
|
||||
}
|
||||
// a non-number value means no read permission for both data and metadata
|
||||
return { data: secretData };
|
||||
}
|
||||
|
||||
normalizeKvData(payload) {
|
||||
const { data, metadata } = payload.data;
|
||||
return {
|
||||
...payload,
|
||||
data: {
|
||||
...payload.data,
|
||||
// Rename to secret_data so it doesn't get removed by normalizer
|
||||
secret_data: data,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
if (requestType === 'queryRecord') {
|
||||
const transformed = this.normalizeKvData(payload);
|
||||
return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType);
|
||||
}
|
||||
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
|
||||
}
|
||||
}
|
||||
39
ui/app/serializers/kv/metadata.js
Normal file
39
ui/app/serializers/kv/metadata.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import { assert } from '@ember/debug';
|
||||
import ApplicationSerializer from '../application';
|
||||
import { kvMetadataPath } from 'vault/utils/kv-path';
|
||||
|
||||
export default class KvMetadataSerializer extends ApplicationSerializer {
|
||||
attrs = {
|
||||
backend: { serialize: false },
|
||||
path: { serialize: false },
|
||||
oldestVersion: { serialize: false },
|
||||
createdTime: { serialize: false },
|
||||
updatedTime: { serialize: false },
|
||||
currentVersion: { serialize: false },
|
||||
versions: { serialize: false },
|
||||
};
|
||||
|
||||
normalizeItems(payload) {
|
||||
if (payload.data.keys) {
|
||||
assert('payload.backend must be provided on kv/metadata list response', !!payload.backend);
|
||||
return payload.data.keys.map((secret) => {
|
||||
// If there is no payload.path then we're either on a "top level" secret or the first level directory of a nested secret.
|
||||
// We set the path to the current secret or pathToSecret. e.g. my-secret or beep/boop/
|
||||
// We add a param called full_secret_path to the model which we use to navigate to the nested secret. e.g. beep/boop/bop.
|
||||
const fullSecretPath = payload.path ? payload.path + secret : secret;
|
||||
return {
|
||||
id: kvMetadataPath(payload.backend, fullSecretPath),
|
||||
path: secret,
|
||||
backend: payload.backend,
|
||||
full_secret_path: fullSecretPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
return super.normalizeItems(payload);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export default class StoreService extends Store {
|
||||
prevPage: clamp(currentPage - 1, 1, lastPage),
|
||||
total: dataset.length || 0,
|
||||
filteredTotal: data.length || 0,
|
||||
pageSize: size,
|
||||
};
|
||||
|
||||
return resp;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
// TODO kv engine cleanup safe to remove this, the new component does not rely on this css
|
||||
|
||||
.visual-diff {
|
||||
background-color: black;
|
||||
|
||||
@@ -110,6 +110,16 @@ pre {
|
||||
}
|
||||
}
|
||||
|
||||
.is-danger {
|
||||
.modal-card-head {
|
||||
background: $red-010;
|
||||
border: 1px solid $red-100;
|
||||
}
|
||||
.modal-card-title {
|
||||
color: $red-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-confirm-section {
|
||||
margin: $spacing-xl 0 $spacing-m;
|
||||
}
|
||||
@@ -118,7 +128,7 @@ pre {
|
||||
background: $ui-gray-050;
|
||||
border-top: $base-border;
|
||||
}
|
||||
|
||||
// kv engine clean up - can remove this style after you remove secret-delete-menu
|
||||
.modal-radio-button {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -133,6 +133,7 @@ a.disabled.toolbar-link {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
// TODO kv engine cleanup
|
||||
.version-diff-toolbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@import './core/file';
|
||||
@import './core/footer';
|
||||
@import './core/inputs';
|
||||
@import './core/json-diff-patch';
|
||||
@import './core/label';
|
||||
@import './core/level';
|
||||
@import './core/link';
|
||||
|
||||
@@ -92,6 +92,12 @@
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&.is-warning-outlined {
|
||||
background-color: $yellow-010;
|
||||
border: 1px solid $yellow-700;
|
||||
color: $yellow-700;
|
||||
}
|
||||
|
||||
&.is-flat {
|
||||
min-width: auto;
|
||||
border: none;
|
||||
|
||||
23
ui/app/styles/core/json-diff-patch.scss
Normal file
23
ui/app/styles/core/json-diff-patch.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
// used in KV version diff view. https://github.com/benjamine/jsondiffpatch/tree/master
|
||||
|
||||
.jsondiffpatch-deleted .jsondiffpatch-property-name,
|
||||
.jsondiffpatch-deleted pre,
|
||||
.jsondiffpatch-modified .jsondiffpatch-left-value pre,
|
||||
.jsondiffpatch-textdiff-deleted {
|
||||
background: $red-500;
|
||||
}
|
||||
.jsondiffpatch-added .jsondiffpatch-property-name,
|
||||
.jsondiffpatch-added .jsondiffpatch-value pre,
|
||||
.jsondiffpatch-modified .jsondiffpatch-right-value pre,
|
||||
.jsondiffpatch-textdiff-added {
|
||||
background: $green-500;
|
||||
}
|
||||
|
||||
.jsondiffpatch-property-name {
|
||||
color: $ui-gray-300;
|
||||
}
|
||||
@@ -18,6 +18,10 @@
|
||||
background-color: $ui-gray-200;
|
||||
}
|
||||
|
||||
.background-color-black {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
// borders
|
||||
.has-border-top-light {
|
||||
border-radius: 0;
|
||||
@@ -40,6 +44,10 @@ select.has-error-border {
|
||||
}
|
||||
|
||||
// text color
|
||||
.text-grey-lightest {
|
||||
color: $grey-lightest;
|
||||
}
|
||||
|
||||
.has-text-grey-light {
|
||||
color: $ui-gray-300 !important;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.align-self-center {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.is-medium-height {
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-xxs {
|
||||
top: $spacing-xxs;
|
||||
}
|
||||
|
||||
// visibility
|
||||
.is-invisible {
|
||||
visibility: hidden;
|
||||
@@ -44,6 +48,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.is-three-fourths-width {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.is-auto-width {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@@ -104,3 +104,7 @@
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.opacity-060 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! TODO kv engine cleanup }}
|
||||
<Toolbar>
|
||||
<div class="version-diff-toolbar" data-test-version-diff-toolbar>
|
||||
{{! Left side version }}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
@isMarginless={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{! TODO kv engine cleanup }}
|
||||
{{#if @modelForData.isFolder}}
|
||||
<p class="help has-text-danger">
|
||||
The secret path may not end in
|
||||
@@ -172,7 +173,7 @@
|
||||
<ul class={{if (and (eq @canReadSecretData false) this.isCreateNewVersionFromOldVersion) "bullet"}}>
|
||||
{{#if (eq @canReadSecretData false)}}
|
||||
<li data-test-warning-no-read-permissions>
|
||||
You do not have read permissions. If a secret exists here creating a new secret will overwrite it.
|
||||
You do not have read permissions. If a secret exists at this path creating a new secret will overwrite it.
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if this.isCreateNewVersionFromOldVersion}}
|
||||
|
||||
@@ -30,59 +30,15 @@
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (and (eq @mode "show") @canUpdateSecretData)}}
|
||||
{{! TODO kv engine cleanup - remove @isV2 logic }}
|
||||
{{#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")}}
|
||||
@htmlTag="button"
|
||||
>
|
||||
Copy
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="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={{stringify @modelForData.secretData}}
|
||||
@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 this.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}}
|
||||
/>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
<CopySecretDropdown
|
||||
@clipboardText={{stringify @modelForData.secretData}}
|
||||
@onWrap={{perform this.wrapSecret}}
|
||||
@isWrapping={{this.wrapSecret.isRunning}}
|
||||
@wrappedData={{this.wrappedData}}
|
||||
@onClose={{this.clearWrappedData}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! TODO kv engine cleanup : this component can remove all logic related to V2 }}
|
||||
{{#if (and @isV2 @modelForData.destroyed)}}
|
||||
<EmptyState
|
||||
@title="Version {{@modelForData.version}} of this secret has been permanently destroyed"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! TODO kv engine cleanup : this component can be deleted in favor of <KvVersionDropdown/> }}
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
|
||||
<D.Trigger
|
||||
data-test-popup-menu-trigger="version"
|
||||
|
||||
@@ -36,19 +36,16 @@
|
||||
@placeholder="Filter leases"
|
||||
/>
|
||||
{{#if this.filterFocused}}
|
||||
{{! template-lint-disable no-whitespace-for-layout }}
|
||||
|
||||
{{! template-lint-enable no-whitespace-for-layout }}
|
||||
{{#if this.filterMatchesKey}}
|
||||
{{#unless this.filterIsFolder}}
|
||||
<p class="help has-text-grey is-size-8">
|
||||
<p class="help has-text-grey is-size-8 has-left-padding-xs">
|
||||
<kbd>ENTER</kbd>
|
||||
to go to see details
|
||||
</p>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{#if this.firstPartialMatch}}
|
||||
<p class="help has-text-grey is-size-8">
|
||||
<p class="help has-text-grey is-size-8 has-left-padding-xs">
|
||||
<kbd>TAB</kbd>
|
||||
to complete
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! TODO kv engine cleanup }}
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
<nav class="menu" aria-label="{{if backend.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
{{! TODO kv engine cleanup: this link will not be accurate for kv config details }}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.configuration" @model={{backend.id}} data-test-engine-config>
|
||||
View configuration
|
||||
</LinkTo>
|
||||
|
||||
32
ui/app/utils/kv-path.ts
Normal file
32
ui/app/utils/kv-path.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* This set of utils is for calculating the full path for a given KV V2 secret, which doubles as its ID.
|
||||
* Additional methods for building URLs for other KV-V2 actions
|
||||
*/
|
||||
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
|
||||
function buildKvPath(backend: string, path: string, type: string, version?: number | string) {
|
||||
const url = `${encodePath(backend)}/${type}/${encodePath(path)}`;
|
||||
return version ? `${url}?version=${version}` : url;
|
||||
}
|
||||
|
||||
export function kvDataPath(backend: string, path: string, version?: number | string) {
|
||||
return buildKvPath(backend, path, 'data', version);
|
||||
}
|
||||
export function kvDeletePath(backend: string, path: string, version?: number | string) {
|
||||
return buildKvPath(backend, path, 'delete', version);
|
||||
}
|
||||
export function kvMetadataPath(backend: string, path: string) {
|
||||
return buildKvPath(backend, path, 'metadata');
|
||||
}
|
||||
export function kvDestroyPath(backend: string, path: string) {
|
||||
return buildKvPath(backend, path, 'destroy');
|
||||
}
|
||||
export function kvUndeletePath(backend: string, path: string) {
|
||||
return buildKvPath(backend, path, 'undelete');
|
||||
}
|
||||
38
ui/lib/core/addon/components/copy-secret-dropdown.hbs
Normal file
38
ui/lib/core/addon/components/copy-secret-dropdown.hbs
Normal file
@@ -0,0 +1,38 @@
|
||||
{{! @onWrap is recommend to be a concurrency task! see <Page::Secret::Details> in KV addon for example }}
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" @onClose={{@onClose}} as |D|>
|
||||
<D.Trigger data-test-copy-menu-trigger class="toolbar-link {{if D.isOpen 'is-active'}}" @htmlTag="button">
|
||||
Copy
|
||||
<Chevron @direction={{if D.isOpen "up" "down"}} @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="popup-menu-content is-wide">
|
||||
<nav class="box menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
<CopyButton
|
||||
@class="link"
|
||||
@clipboardText={{@clipboardText}}
|
||||
@success={{fn (set-flash-message "JSON Copied!")}}
|
||||
data-test-copy-button
|
||||
>
|
||||
Copy JSON
|
||||
</CopyButton>
|
||||
</li>
|
||||
<li class="action">
|
||||
{{#if @wrappedData}}
|
||||
<MaskedInput @class="has-padding" @displayOnly={{true}} @allowCopy={{true}} @value={{@wrappedData}} />
|
||||
{{else}}
|
||||
<button
|
||||
class="link button {{if @isWrapping 'is-loading'}}"
|
||||
type="button"
|
||||
{{on "click" @onWrap}}
|
||||
disabled={{@isWrapping}}
|
||||
data-test-wrap-button
|
||||
>
|
||||
Wrap secret
|
||||
</button>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
@@ -7,7 +7,24 @@ import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class inputSelect extends Component {
|
||||
/**
|
||||
* @module InputSearch
|
||||
* This component renders an input that fires a callback on "keyup" containing the input's value
|
||||
*
|
||||
* @example
|
||||
* <InputSearch
|
||||
* @initialValue="secret/path/"
|
||||
* @onChange={{this.handleSearch}}
|
||||
* @placeholder="search..."
|
||||
* />
|
||||
* @param {string} [id] - unique id for the input
|
||||
* @param {string} [initialValue] - initial search value, i.e. a secret path prefix, that pre-fills the input field
|
||||
* @param {string} [placeholder] - placeholder text for the input
|
||||
* @param {string} [label] - label for the input
|
||||
* @param {string} [subtext] - displays below the label
|
||||
*/
|
||||
|
||||
export default class InputSearch extends Component {
|
||||
/*
|
||||
* @public
|
||||
* @param Function
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="columns is-variable" data-test-kv-row>
|
||||
<div class="column is-one-quarter">
|
||||
<Input
|
||||
data-test-kv-key={{true}}
|
||||
data-test-kv-key={{index}}
|
||||
@value={{row.name}}
|
||||
placeholder={{this.placeholders.key}}
|
||||
{{on "change" (fn this.updateRow row index)}}
|
||||
@@ -31,6 +31,13 @@
|
||||
<div class="column">
|
||||
{{#if (has-block)}}
|
||||
{{yield row this.kvData}}
|
||||
{{else if @isMasked}}
|
||||
<MaskedInput
|
||||
data-test-kv-value={{index}}
|
||||
@name={{row.name}}
|
||||
@onChange={{fn this.updateRow row index}}
|
||||
@value={{row.value}}
|
||||
/>
|
||||
{{else}}
|
||||
<Textarea
|
||||
data-test-kv-value={{index}}
|
||||
@@ -47,7 +54,7 @@
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{#if (eq this.kvData.length (inc index))}}
|
||||
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
|
||||
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{index}}>
|
||||
Add
|
||||
</button>
|
||||
{{else}}
|
||||
|
||||
@@ -25,6 +25,7 @@ import KVObject from 'vault/lib/kv-object';
|
||||
* ```
|
||||
* @param {string} value - the value is captured from the model.
|
||||
* @param {function} onChange - function that captures the value on change
|
||||
* @param {boolean} [isMasked = false] - when true the <MaskedInput> renders instead of the default <textarea> to input the value portion of the key/value object
|
||||
* @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} [labelClass] - override default label class in FormFieldLabel component
|
||||
|
||||
@@ -12,8 +12,7 @@ import Component from '@glimmer/component';
|
||||
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import { keyIsFolder, parentKeyForKey } from 'core/utils/key-utils';
|
||||
// TODO MOVE THESE TO THE ADDON
|
||||
import keys from 'vault/lib/keycodes';
|
||||
import keys from 'core/utils/key-codes';
|
||||
|
||||
/**
|
||||
* @module NavigateInput
|
||||
|
||||
@@ -5,15 +5,21 @@
|
||||
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" data-test-breadcrumbs>
|
||||
<ul>
|
||||
{{#each @breadcrumbs as |breadcrumb|}}
|
||||
<li>
|
||||
{{#each @breadcrumbs as |breadcrumb idx|}}
|
||||
<li data-test-crumb="{{idx}}">
|
||||
<span class="sep">/</span>
|
||||
{{#if breadcrumb.linkExternal}}
|
||||
<LinkToExternal @route={{breadcrumb.route}}>{{breadcrumb.label}}</LinkToExternal>
|
||||
{{else if breadcrumb.route}}
|
||||
<LinkTo @route={{breadcrumb.route}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkTo>
|
||||
{{#if breadcrumb.model}}
|
||||
<LinkTo @route={{breadcrumb.route}} @model={{breadcrumb.model}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<LinkTo @route={{breadcrumb.route}}>
|
||||
{{breadcrumb.label}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{breadcrumb.label}}
|
||||
{{/if}}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default class Breadcrumbs extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.args.breadcrumbs.forEach((breadcrumb) => {
|
||||
assert('breadcrumb has a label key', Object.keys(breadcrumb).includes('label'));
|
||||
assert('breadcrumb must have a label key', Object.keys(breadcrumb).includes('label'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ export default {
|
||||
RIGHT: 39,
|
||||
DOWN: 40,
|
||||
T: 116,
|
||||
BACKSPACE: 8,
|
||||
};
|
||||
1
ui/lib/core/app/components/copy-secret-dropdown.js
Normal file
1
ui/lib/core/app/components/copy-secret-dropdown.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'core/components/copy-secret-dropdown';
|
||||
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/format-duration';
|
||||
export { default, duration } from 'core/helpers/format-duration';
|
||||
|
||||
6
ui/lib/core/app/helpers/stringify.js
Normal file
6
ui/lib/core/app/helpers/stringify.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/stringify';
|
||||
6
ui/lib/core/app/helpers/to-label.js
Normal file
6
ui/lib/core/app/helpers/to-label.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/to-label';
|
||||
33
ui/lib/kv/addon/components/kv-data-fields.hbs
Normal file
33
ui/lib/kv/addon/components/kv-data-fields.hbs
Normal file
@@ -0,0 +1,33 @@
|
||||
{{#let (find-by "name" "path" @secret.allFields) as |attr|}}
|
||||
{{#if @isEdit}}
|
||||
<ReadonlyFormField @attr={{attr}} @value={{get @secret attr.name}} />
|
||||
{{else}}
|
||||
<FormField @attr={{attr}} @model={{@secret}} @modelValidations={{@modelValidations}} @onKeyUp={{@pathValidations}} />
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
{{#if @showJson}}
|
||||
<JsonEditor
|
||||
@title="{{if @isEdit 'Version' 'Secret'}} data"
|
||||
@value={{or (stringify @secret.secretData) this.emptyJson}}
|
||||
@valueUpdated={{this.handleJson}}
|
||||
/>
|
||||
{{#if (or @modelValidations.secretData.errors this.lintingErrors)}}
|
||||
<AlertInline @type={{if this.lintingErrors "warning" "danger"}} @paddingTop={{true}}>
|
||||
{{#if @modelValidations.secretData.errors}}
|
||||
{{@modelValidations.secretData.errors}}
|
||||
{{else}}
|
||||
JSON is unparsable. Fix linting errors to avoid data discrepancies.
|
||||
{{/if}}
|
||||
</AlertInline>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<KvObjectEditor
|
||||
class="has-top-margin-m"
|
||||
@label="{{if @isEdit 'Version' 'Secret'}} data"
|
||||
@value={{@secret.secretData}}
|
||||
@onChange={{fn (mut @secret.secretData)}}
|
||||
@isMasked={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
45
ui/lib/kv/addon/components/kv-data-fields.js
Normal file
45
ui/lib/kv/addon/components/kv-data-fields.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import KVObject from 'vault/lib/kv-object';
|
||||
|
||||
/**
|
||||
* @module KvDataFields is used for rendering the fields associated with kv secret data, it hides/shows a json editor and renders validation errors for the json editor
|
||||
*
|
||||
* <KvDataFields
|
||||
* @showJson={{true}}
|
||||
* @secret={{@secret}}
|
||||
* @isEdit={{true}}
|
||||
* @modelValidations={{this.modelValidations}}
|
||||
* @pathValidations={{this.pathValidations}}
|
||||
* />
|
||||
*
|
||||
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
|
||||
* @param {boolean} showJson - boolean passed from parent to hide/show json editor
|
||||
* @param {object} [modelValidations] - object of errors. If attr.name is in object and has error message display in AlertInline.
|
||||
* @param {callback} [pathValidations] - callback function fired for the path input on key up
|
||||
* @param {boolean} [isEdit=false] - if true, this is a new secret version rather than a new secret. Used to change text for some form labels
|
||||
*/
|
||||
|
||||
export default class KvDataFields extends Component {
|
||||
@tracked lintingErrors;
|
||||
|
||||
get emptyJson() {
|
||||
// if secretData is null, this specially formats a blank object and renders a nice initial state for the json editor
|
||||
return KVObject.create({ content: [{ name: '', value: '' }] }).toJSONString(true);
|
||||
}
|
||||
|
||||
@action
|
||||
handleJson(value, codemirror) {
|
||||
codemirror.performLint();
|
||||
this.lintingErrors = codemirror.state.lint.marked.length > 0;
|
||||
if (!this.lintingErrors) {
|
||||
this.args.secret.secretData = JSON.parse(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
ui/lib/kv/addon/components/kv-delete-modal.hbs
Normal file
61
ui/lib/kv/addon/components/kv-delete-modal.hbs
Normal file
@@ -0,0 +1,61 @@
|
||||
<button type="button" class="toolbar-link" {{on "click" (fn (mut this.modalOpen) true)}} data-test-kv-delete={{@mode}}>
|
||||
{{yield}}
|
||||
</button>
|
||||
{{#if this.modalOpen}}
|
||||
<Modal
|
||||
@title={{this.modalDisplay.title}}
|
||||
@onClose={{fn (mut this.modalOpen) false}}
|
||||
@isActive={{this.modalOpen}}
|
||||
@type={{this.modalDisplay.type}}
|
||||
@showCloseButton={{true}}
|
||||
>
|
||||
<section class="modal-card-body">
|
||||
<p class="has-bottom-margin-s">
|
||||
{{this.modalDisplay.intro}}
|
||||
</p>
|
||||
{{#if (eq @mode "delete")}}
|
||||
<div class="is-flex-column">
|
||||
{{#each this.deleteOptions as |option|}}
|
||||
<ToolTip @verticalPosition="above" @horizontalPosition="left" as |T|>
|
||||
<T.Trigger @tabindex="-1">
|
||||
<div class="is-flex-align-baseline has-bottom-margin-m">
|
||||
<RadioButton
|
||||
id={{option.key}}
|
||||
class="radio top-xxs"
|
||||
@disabled={{option.disabled}}
|
||||
@value={{option.key}}
|
||||
@groupValue={{this.deleteType}}
|
||||
@onChange={{fn (mut this.deleteType) option.key}}
|
||||
/>
|
||||
<label for={{option.key}} class="has-left-margin-s {{if option.disabled 'opacity-060'}}">
|
||||
<p class="has-text-weight-semibold">{{option.label}}</p>
|
||||
<p>{{option.description}}</p>
|
||||
</label>
|
||||
</div>
|
||||
</T.Trigger>
|
||||
{{#if option.disabled}}
|
||||
<T.Content @defaultClass="tool-tip">
|
||||
<div class="box">
|
||||
{{option.tooltipMessage}}
|
||||
</div>
|
||||
</T.Content>
|
||||
{{/if}}
|
||||
</ToolTip>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
<footer class="modal-card-foot modal-card-foot-outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="button {{if (eq this.modalDisplay.type 'danger') 'is-danger-outlined' 'is-warning-outlined'}}"
|
||||
{{on "click" this.onDelete}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="button is-secondary" {{on "click" (fn (mut this.modalOpen) false)}}>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{{/if}}
|
||||
88
ui/lib/kv/addon/components/kv-delete-modal.js
Normal file
88
ui/lib/kv/addon/components/kv-delete-modal.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
/**
|
||||
* @module KvDeleteModal displays a button for a delete type and launches a modal. Undelete is the only mode that does not launch the modal and is not handled in this component.
|
||||
*
|
||||
* <KvDeleteModal
|
||||
* @mode="destroy"
|
||||
* @secret={{this.model.secret}}
|
||||
* @metadata={{this.model.metadata}}
|
||||
* @onDelete={{this.handleDestruction}}
|
||||
* />
|
||||
*
|
||||
* @param {string} mode - delete, delete-metadata, or destroy.
|
||||
* @param {object} secret - The kv/data model.
|
||||
* @param {object} [metadata] - The kv/metadata model. It is only required when mode is "delete" or "metadata-delete".
|
||||
* @param {callback} onDelete - callback function fired to handle delete event.
|
||||
*/
|
||||
|
||||
export default class KvDeleteModal extends Component {
|
||||
@tracked deleteType = null; // Either delete-version or delete-current-version.
|
||||
@tracked modalOpen = false;
|
||||
|
||||
get modalDisplay() {
|
||||
switch (this.args.mode) {
|
||||
// Does not match adapter key directly because a delete type must be selected.
|
||||
case 'delete':
|
||||
return {
|
||||
title: 'Delete version?',
|
||||
type: 'warning',
|
||||
intro:
|
||||
'There are two ways to delete a version of a secret. Both delete actions can be undeleted later. How would you like to proceed?',
|
||||
};
|
||||
case 'destroy':
|
||||
return {
|
||||
title: 'Destroy version?',
|
||||
type: 'danger',
|
||||
intro: `This action will permanently destroy Version ${this.args.secret.version} of the secret, and the secret data cannot be read or recovered later.`,
|
||||
};
|
||||
case 'delete-metadata':
|
||||
return {
|
||||
title: 'Delete metadata?',
|
||||
type: 'danger',
|
||||
intro:
|
||||
'This will permanently delete the metadata and versions of the secret. All version history will be removed. This cannot be undone.',
|
||||
};
|
||||
default:
|
||||
return assert('mode must be one of delete, destroy, or delete-metadata.');
|
||||
}
|
||||
}
|
||||
|
||||
get deleteOptions() {
|
||||
const { secret, metadata } = this.args;
|
||||
const isDeactivated = secret.canReadMetadata ? metadata?.currentSecret.isDeactivated : false;
|
||||
return [
|
||||
{
|
||||
key: 'delete-version',
|
||||
label: 'Delete this version',
|
||||
description: `This deletes Version ${secret.version} of the secret.`,
|
||||
disabled: !secret.canDeleteVersion,
|
||||
tooltipMessage: 'You do not have permission to delete a specific version.',
|
||||
},
|
||||
{
|
||||
key: 'delete-latest-version',
|
||||
label: 'Delete latest version',
|
||||
description: 'This deletes the most recent version of the secret.',
|
||||
disabled: !secret.canDeleteLatestVersion || isDeactivated,
|
||||
tooltipMessage: isDeactivated
|
||||
? `The latest version of the secret is already ${metadata.currentSecret.state}.`
|
||||
: 'You do not have permission to delete the latest version of this secret.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@action
|
||||
onDelete() {
|
||||
const type = this.args.mode === 'delete' ? this.deleteType : this.args.mode;
|
||||
this.args.onDelete(type);
|
||||
this.modalOpen = false;
|
||||
}
|
||||
}
|
||||
35
ui/lib/kv/addon/components/kv-list-filter.hbs
Normal file
35
ui/lib/kv/addon/components/kv-list-filter.hbs
Normal file
@@ -0,0 +1,35 @@
|
||||
<div class="navigate-filter">
|
||||
<div class="field" data-test-nav-input>
|
||||
<p class="control has-icons-left">
|
||||
<Input
|
||||
id="secret-filter"
|
||||
class="filter input"
|
||||
placeholder="Filter secrets"
|
||||
@value={{@filterValue}}
|
||||
@type="text"
|
||||
data-test-component="kv-list-filter"
|
||||
{{on "input" this.handleInput}}
|
||||
{{on "keydown" this.handleKeyDown}}
|
||||
{{on "focus" this.setFilterIsFocused}}
|
||||
{{did-insert this.focusInput}}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Icon @name="search" class="search-icon has-text-grey-light" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{#if (and this.filterIsFocused this.isFilterMatch)}}
|
||||
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-enter>
|
||||
<kbd>ENTER</kbd>
|
||||
to go to see details.
|
||||
<kbd>ESC</kbd>
|
||||
to clear input.
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-tab>
|
||||
<kbd>TAB</kbd>
|
||||
to autocomplete.
|
||||
<kbd>ESC</kbd>
|
||||
to clear input.
|
||||
</p>
|
||||
{{/if}}
|
||||
166
ui/lib/kv/addon/components/kv-list-filter.js
Normal file
166
ui/lib/kv/addon/components/kv-list-filter.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import keys from 'core/utils/key-codes';
|
||||
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
|
||||
import escapeStringRegexp from 'escape-string-regexp';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module KvListFilter
|
||||
* `KvListFilter` filters through the KV metadata LIST response. It allows users to search through the current list, navigate into directories, and use keyboard functions to: autocomplete, view a secret, create a new secret, or clear the input field.
|
||||
* *
|
||||
* <KvListFilter
|
||||
* @secrets={{this.model.secrets}}
|
||||
* @mountPoint={{this.model.mountPoint}}
|
||||
* @filterValue="beep/my-"
|
||||
* @pageFilter="my-"
|
||||
* />
|
||||
* @param {array} secrets - An array of secret models.
|
||||
* @param {string} mountPoint - Where in the router files we're located. For this component it will always be vault.cluster.secrets.backend.kv
|
||||
* @param {string} filterValue - A concatenation between the list-directory's dynamic path "path-to-secret" and the queryParam "pageFilter". For example, if we're inside the beep/ directory searching for any secret that starts with "my-" this value will equal "beep/my-".
|
||||
* @param {string} pageFilter - The queryParam value, does not include pathToSecret ex: my-.
|
||||
*/
|
||||
|
||||
export default class KvListFilterComponent extends Component {
|
||||
@service router;
|
||||
@tracked filterIsFocused = false;
|
||||
|
||||
navigate(pathToSecret, pageFilter) {
|
||||
const route = pathToSecret ? `${this.args.mountPoint}.list-directory` : `${this.args.mountPoint}.list`;
|
||||
const args = [route];
|
||||
if (pathToSecret) {
|
||||
args.push(pathToSecret);
|
||||
}
|
||||
args.push({
|
||||
queryParams: {
|
||||
pageFilter: pageFilter ? pageFilter : null,
|
||||
},
|
||||
});
|
||||
this.router.transitionTo(...args);
|
||||
}
|
||||
|
||||
/*
|
||||
- partialMatch returns the secret that most closely matches the pageFilter queryParam.
|
||||
- Searches pageFilter and not filterValue because if we're inside a directory we only care about the secrets listed there and not the directory.
|
||||
- If pageFilter is empty this returns the first secret model in the list.
|
||||
**/
|
||||
get partialMatch() {
|
||||
// If pageFilter is empty we replace it with an empty string because you cannot pass 'undefined' to RegEx.
|
||||
const value = !this.args.pageFilter ? '' : this.args.pageFilter;
|
||||
const reg = new RegExp('^' + escapeStringRegexp(value));
|
||||
const match = this.args.secrets.filter((path) => reg.test(path.fullSecretPath))[0];
|
||||
if (this.isFilterMatch || !match) return null;
|
||||
|
||||
return match.fullSecretPath;
|
||||
}
|
||||
/*
|
||||
- isFilterMatch returns true if the filterValue matches a fullSecretPath.
|
||||
**/
|
||||
get isFilterMatch() {
|
||||
return !!this.args.secrets?.findBy('fullSecretPath', this.args.filterValue);
|
||||
}
|
||||
/*
|
||||
-handleInput is triggered after the value of the input has changed. It is not triggered when input looses focus.
|
||||
**/
|
||||
@action
|
||||
handleInput(event) {
|
||||
const input = event.target.value;
|
||||
const isDirectory = keyIsFolder(input);
|
||||
const parentDirectory = parentKeyForKey(input);
|
||||
const secretWithinDirectory = keyWithoutParentKey(input);
|
||||
|
||||
if (isDirectory) {
|
||||
this.navigate(input);
|
||||
} else if (parentDirectory) {
|
||||
this.navigate(parentDirectory, secretWithinDirectory);
|
||||
} else {
|
||||
this.navigate(null, input);
|
||||
}
|
||||
}
|
||||
/*
|
||||
-handleKeyDown handles: tab, enter, backspace and escape. Ignores everything else.
|
||||
**/
|
||||
@action
|
||||
handleKeyDown(event) {
|
||||
const input = event.target.value;
|
||||
const parentDirectory = parentKeyForKey(input);
|
||||
|
||||
if (event.keyCode === keys.BACKSPACE) {
|
||||
this.handleBackspace(input, parentDirectory);
|
||||
}
|
||||
|
||||
if (event.keyCode === keys.TAB) {
|
||||
event.preventDefault();
|
||||
this.handleTab();
|
||||
}
|
||||
|
||||
if (event.keyCode === keys.ENTER) {
|
||||
event.preventDefault();
|
||||
this.handleEnter(input);
|
||||
}
|
||||
|
||||
if (event.keyCode === keys.ESC) {
|
||||
this.handleEscape(parentDirectory);
|
||||
}
|
||||
// ignore all other key events
|
||||
return;
|
||||
}
|
||||
// key-code specific methods
|
||||
handleBackspace(input, parentDirectory) {
|
||||
const isInputDirectory = keyIsFolder(input);
|
||||
const inputWithoutParentKey = keyWithoutParentKey(input);
|
||||
const pageFilter = isInputDirectory ? '' : inputWithoutParentKey.slice(0, -1);
|
||||
this.navigate(parentDirectory, pageFilter);
|
||||
}
|
||||
handleTab() {
|
||||
const isMatchDirectory = keyIsFolder(this.partialMatch);
|
||||
const matchParentDirectory = parentKeyForKey(this.partialMatch);
|
||||
const matchWithinDirectory = keyWithoutParentKey(this.partialMatch);
|
||||
|
||||
if (isMatchDirectory) {
|
||||
// ex: beep/boop/
|
||||
this.navigate(this.partialMatch);
|
||||
} else if (!isMatchDirectory && matchParentDirectory) {
|
||||
// ex: beep/boop/my-
|
||||
this.navigate(matchParentDirectory, matchWithinDirectory);
|
||||
} else {
|
||||
// ex: my-
|
||||
this.navigate(null, this.partialMatch);
|
||||
}
|
||||
}
|
||||
handleEnter(input) {
|
||||
if (this.isFilterMatch) {
|
||||
// if secret exists send to details
|
||||
this.router.transitionTo(`${this.args.mountPoint}.secret.details`, input);
|
||||
} else {
|
||||
// if secret does not exists send to create with the path prefilled with input value.
|
||||
this.router.transitionTo(`${this.args.mountPoint}.create`, {
|
||||
queryParams: { initialKey: input },
|
||||
});
|
||||
}
|
||||
}
|
||||
handleEscape(parentDirectory) {
|
||||
// transition to the nearest parentDirectory. If no parentDirectory, then to the list route.
|
||||
!parentDirectory ? this.navigate() : this.navigate(parentDirectory);
|
||||
}
|
||||
|
||||
@action
|
||||
setFilterIsFocused() {
|
||||
// tracked property used to show or hide the help-text next to the input. Not involved in focus event itself.
|
||||
this.filterIsFocused = true;
|
||||
}
|
||||
|
||||
@action
|
||||
focusInput() {
|
||||
// set focus to the input when there is either a pageFilter queryParam value and/or list-directory's dynamic path-to-secret has a value.
|
||||
if (this.args.filterValue) {
|
||||
document.getElementById('secret-filter')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ui/lib/kv/addon/components/kv-metadata-fields.hbs
Normal file
10
ui/lib/kv/addon/components/kv-metadata-fields.hbs
Normal file
@@ -0,0 +1,10 @@
|
||||
{{#each @metadata.formFields as |attr|}}
|
||||
{{#if (eq attr.name "customMetadata")}}
|
||||
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
|
||||
<label class="title has-top-padding-m is-4">
|
||||
Additional options
|
||||
</label>
|
||||
{{else}}
|
||||
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
37
ui/lib/kv/addon/components/kv-page-header.hbs
Normal file
37
ui/lib/kv/addon/components/kv-page-header.hbs
Normal file
@@ -0,0 +1,37 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-header-title>
|
||||
{{#if @mountName}}
|
||||
<Icon @name="kv" @size="24" class="has-text-grey-light" />
|
||||
{{@mountName}}
|
||||
<span class="tag">Version 2</span>
|
||||
{{else}}
|
||||
{{@pageTitle}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if (has-block "tabLinks")}}
|
||||
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||
<nav class="tabs" aria-label="kv tabs">
|
||||
<ul>
|
||||
{{yield to="tabLinks"}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (or (has-block "toolbarFilters") (has-block "toolbarActions"))}}
|
||||
<Toolbar>
|
||||
<ToolbarFilters>
|
||||
{{yield to="toolbarFilters"}}
|
||||
</ToolbarFilters>
|
||||
<ToolbarActions>
|
||||
{{yield to="toolbarActions"}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
14
ui/lib/kv/addon/components/kv-tooltip-timestamp.hbs
Normal file
14
ui/lib/kv/addon/components/kv-tooltip-timestamp.hbs
Normal file
@@ -0,0 +1,14 @@
|
||||
{{! renders a human readable date with a tooltip containing the API timestamp}}
|
||||
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
|
||||
<T.Trigger data-test-kv-version-tooltip-trigger tabindex="-1">
|
||||
{{#if @text}}
|
||||
{{@text}}
|
||||
{{/if}}
|
||||
{{date-format @timestamp "MMM dd, yyyy hh:mm a"}}
|
||||
</T.Trigger>
|
||||
<T.Content @defaultClass="tool-tip smaller-font">
|
||||
<div class="box" data-test-hover-copy-tooltip-text>
|
||||
{{@timestamp}}
|
||||
</div>
|
||||
</T.Content>
|
||||
</ToolTip>
|
||||
37
ui/lib/kv/addon/components/kv-version-dropdown.hbs
Normal file
37
ui/lib/kv/addon/components/kv-version-dropdown.hbs
Normal file
@@ -0,0 +1,37 @@
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
|
||||
<D.Trigger data-test-version-dropdown class="toolbar-link {{if D.isOpen ' is-active'}}">
|
||||
Version
|
||||
{{or @displayVersion "current"}}
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="popup-menu-content">
|
||||
<nav class="box menu">
|
||||
<ul class="menu-list">
|
||||
{{#each @metadata.sortedVersions as |versionData|}}
|
||||
<li data-test-version={{versionData.version}} class="action">
|
||||
<LinkTo @query={{hash version=versionData.version}} {{on "click" (fn @onClose D)}}>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.deletion_time}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
|
||||
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
{{! version diff }}
|
||||
{{#if (gt @metadata.sortedVersions.length 1)}}
|
||||
<hr />
|
||||
<li>
|
||||
<LinkTo @route="secret.metadata.diff" {{on "click" (fn @onClose D)}}>
|
||||
Version Diff
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
32
ui/lib/kv/addon/components/page/configuration.hbs
Normal file
32
ui/lib/kv/addon/components/page/configuration.hbs
Normal file
@@ -0,0 +1,32 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@mountConfig.id}}>
|
||||
<:tabLinks>
|
||||
<LinkTo @route="list" data-test-secrets-tab="Secrets">Secrets</LinkTo>
|
||||
<LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo>
|
||||
</:tabLinks>
|
||||
</KvPageHeader>
|
||||
|
||||
{{! engine configuration }}
|
||||
{{#if @engineConfig.canRead}}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each @engineConfig.formFields as |attr|}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label={{or attr.options.label (to-label attr.name)}}
|
||||
@value={{if (eq attr.name "deleteVersionAfter") @engineConfig.displayDeleteTtl (get @engineConfig attr.name)}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{! mount configuration }}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each @mountConfig.attrs as |attr|}}
|
||||
{{#if (not (includes attr.name @engineConfig.displayFields))}}
|
||||
<InfoTableRow
|
||||
@formatTtl={{eq attr.options.editType "ttl"}}
|
||||
@label={{or attr.options.label (to-label attr.name)}}
|
||||
@value={{get @mountConfig (or attr.options.fieldValue attr.name)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
145
ui/lib/kv/addon/components/page/list.hbs
Normal file
145
ui/lib/kv/addon/components/page/list.hbs
Normal file
@@ -0,0 +1,145 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@backend}}>
|
||||
<:tabLinks>
|
||||
<LinkTo @route={{this.router.currentRoute.localName}} data-test-secrets-tab="Secrets">Secrets</LinkTo>
|
||||
<LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo>
|
||||
</:tabLinks>
|
||||
|
||||
<:toolbarFilters>
|
||||
{{#unless @noMetadataListPermissions}}
|
||||
{{#if (or @secrets @filterValue)}}
|
||||
<KvListFilter
|
||||
@secrets={{@secrets}}
|
||||
@mountPoint={{this.mountPoint}}
|
||||
@filterValue={{@filterValue}}
|
||||
@pageFilter={{@pageFilter}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</:toolbarFilters>
|
||||
|
||||
<:toolbarActions>
|
||||
<ToolbarLink data-test-toolbar-create-secret @route="create" @query={{hash initialKey=@filterValue}} @type="add">
|
||||
Create secret
|
||||
</ToolbarLink>
|
||||
</:toolbarActions>
|
||||
</KvPageHeader>
|
||||
|
||||
{{#if @noMetadataListPermissions}}
|
||||
<div class="box is-fullwidth is-shadowless has-tall-padding">
|
||||
<div class="selectable-card-container one-card">
|
||||
<OverviewCard
|
||||
@cardTitle="View secret"
|
||||
@subText="Type the path of the secret you want to view. Include a trailing slash to navigate to the list view."
|
||||
>
|
||||
<form {{on "submit" this.transitionToSecretDetail}} class="has-top-margin-m is-flex">
|
||||
<InputSearch
|
||||
@id="search-input-kv-secret"
|
||||
@initialValue={{@pathToSecret}}
|
||||
@onChange={{this.handleSecretPathInput}}
|
||||
@placeholder="secret/"
|
||||
data-test-view-secret
|
||||
/>
|
||||
<button type="submit" class="button is-secondary" disabled={{not this.secretPath}} data-test-get-secret-detail>
|
||||
{{this.buttonText}}
|
||||
</button>
|
||||
</form>
|
||||
</OverviewCard>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if @secrets}}
|
||||
{{#each @secrets as |metadata|}}
|
||||
<LinkedBlock
|
||||
data-test-list-item={{metadata.path}}
|
||||
class="list-item-row"
|
||||
@params={{array (if metadata.pathIsDirectory "list-directory" "secret.details") metadata.fullSecretPath}}
|
||||
@linkPrefix={{this.mountPoint}}
|
||||
>
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<Icon @name="user" class="has-text-grey-light" />
|
||||
<span class="has-text-weight-semibold is-underline">
|
||||
{{metadata.path}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right is-flex is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<PopupMenu>
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
{{#if metadata.pathIsDirectory}}
|
||||
<li>
|
||||
<LinkTo @route="list-directory" @model={{metadata.fullSecretPath}}>
|
||||
Content
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
<LinkTo @route="secret.details" @model={{metadata.fullSecretPath}}>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#if metadata.canReadMetadata}}
|
||||
<li>
|
||||
<LinkTo @route="secret.metadata.versions" @model={{metadata.fullSecretPath}}>
|
||||
View version history
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if metadata.canCreateVersionData}}
|
||||
<li>
|
||||
<LinkTo @route="secret.details.edit" @model={{metadata.fullSecretPath}}>
|
||||
Create new version
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if metadata.canDeleteMetadata}}
|
||||
<li>
|
||||
<ConfirmAction
|
||||
@buttonClasses="link is-destroy"
|
||||
@onConfirmAction={{fn this.onDelete metadata}}
|
||||
@confirmMessage="This will permanently delete this secret and all its versions."
|
||||
@cancelButtonText="Cancel"
|
||||
data-test-delete-metadata={{metadata.path}}
|
||||
>
|
||||
Permanently delete
|
||||
</ConfirmAction>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkedBlock>
|
||||
{{/each}}
|
||||
{{! Pagination }}
|
||||
<Hds::Pagination::Numbered
|
||||
@currentPage={{@secrets.meta.currentPage}}
|
||||
@currentPageSize={{@secrets.meta.pageSize}}
|
||||
@route={{this.router.currentRoute.localName}}
|
||||
@showSizeSelector={{false}}
|
||||
@totalItems={{@secrets.meta.total}}
|
||||
@queryFunction={{this.paginationQueryParams}}
|
||||
data-test-pagination
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @filterValue}}
|
||||
<EmptyState @title="There are no secrets matching "{{@filterValue}}"." />
|
||||
{{else}}
|
||||
<EmptyState
|
||||
data-test-secret-list-empty-state
|
||||
@title="No secrets yet"
|
||||
@message="When created, secrets will be listed here. Create a secret to get started."
|
||||
>
|
||||
<LinkTo class="has-top-margin-xs" @route="create">
|
||||
Create secret
|
||||
</LinkTo>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
88
ui/lib/kv/addon/components/page/list.js
Normal file
88
ui/lib/kv/addon/components/page/list.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { ancestorKeysForKey } from 'core/utils/key-utils';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
/**
|
||||
* @module List
|
||||
* ListPage component is a component to show a list of kv/metadata secrets.
|
||||
*
|
||||
* @param {array} secrets - An array of models generated form kv/metadata query.
|
||||
* @param {string} backend - The name of the kv secret engine.
|
||||
* @param {string} pathToSecret - The directory name that the secret belongs to ex: beep/boop/
|
||||
* @param {string} pageFilter - The input on the kv-list-filter. Does not include a directory name.
|
||||
* @param {string} filterValue - The concatenation of the pathToSecret and pageFilter ex: beep/boop/my-
|
||||
* @param {boolean} noMetadataListPermissions - true if the return to query metadata LIST is 403, indicating the user does not have permissions to that endpoint.
|
||||
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
|
||||
*/
|
||||
|
||||
export default class KvListPageComponent extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
@tracked secretPath;
|
||||
|
||||
get mountPoint() {
|
||||
// mountPoint tells transition where to start. In this case, mountPoint will always be vault.cluster.secrets.backend.kv.
|
||||
return getOwner(this).mountPoint;
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return pathIsDirectory(this.secretPath) ? 'View list' : 'View secret';
|
||||
}
|
||||
|
||||
// callback from HDS pagination to set the queryParams currentPage
|
||||
get paginationQueryParams() {
|
||||
return (page) => {
|
||||
return {
|
||||
currentPage: page,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
async onDelete(model) {
|
||||
try {
|
||||
// The model passed in is a kv/metadata model
|
||||
await model.destroyRecord();
|
||||
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
|
||||
const message = `Successfully deleted the metadata and all version data of the secret ${model.fullSecretPath}.`;
|
||||
this.flashMessages.success(message);
|
||||
// if you've deleted a secret from within a directory, transition to its parent directory.
|
||||
if (this.router.currentRoute.localName === 'list-directory') {
|
||||
const ancestors = ancestorKeysForKey(model.fullSecretPath);
|
||||
const nearest = ancestors.pop();
|
||||
this.router.transitionTo(`${this.mountPoint}.list-directory`, nearest);
|
||||
} else {
|
||||
// Transition to refresh the model
|
||||
this.router.transitionTo(`${this.mountPoint}.list`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, 'Error deleting secret. Please try again or contact support.');
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleSecretPathInput(value) {
|
||||
this.secretPath = value;
|
||||
}
|
||||
|
||||
@action
|
||||
transitionToSecretDetail(evt) {
|
||||
evt.preventDefault();
|
||||
pathIsDirectory(this.secretPath)
|
||||
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', this.secretPath)
|
||||
: this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', this.secretPath);
|
||||
}
|
||||
}
|
||||
93
ui/lib/kv/addon/components/page/secret/details.hbs
Normal file
93
ui/lib/kv/addon/components/page/secret/details.hbs
Normal file
@@ -0,0 +1,93 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
||||
<:tabLinks>
|
||||
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
|
||||
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
|
||||
{{#if @secret.canReadMetadata}}
|
||||
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
|
||||
{{/if}}
|
||||
</:tabLinks>
|
||||
|
||||
<:toolbarFilters>
|
||||
{{#unless this.emptyState}}
|
||||
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
|
||||
<span class="has-text-grey">JSON</span>
|
||||
</Toggle>
|
||||
{{/unless}}
|
||||
</:toolbarFilters>
|
||||
<:toolbarActions>
|
||||
{{#if this.showUndelete}}
|
||||
<button type="button" class="toolbar-link" {{on "click" this.undelete}}>
|
||||
Undelete
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if this.showDelete}}
|
||||
<KvDeleteModal @mode="delete" @secret={{@secret}} @metadata={{@metadata}} @onDelete={{this.handleDestruction}}>
|
||||
Delete
|
||||
</KvDeleteModal>
|
||||
{{/if}}
|
||||
{{#if this.showDestroy}}
|
||||
<KvDeleteModal @mode="destroy" @secret={{@secret}} @onDelete={{this.handleDestruction}}>
|
||||
Destroy
|
||||
</KvDeleteModal>
|
||||
{{/if}}
|
||||
<div class="toolbar-separator"></div>
|
||||
{{#if (and @secret.canReadData (eq @secret.state "created"))}}
|
||||
<CopySecretDropdown
|
||||
@clipboardText={{stringify @secret.secretData}}
|
||||
@onWrap={{perform this.wrapSecret}}
|
||||
@isWrapping={{this.wrapSecret.isRunning}}
|
||||
@wrappedData={{this.wrappedData}}
|
||||
@onClose={{this.clearWrappedData}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if @secret.canReadMetadata}}
|
||||
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
|
||||
{{/if}}
|
||||
{{#if @secret.canEditData}}
|
||||
<ToolbarLink data-test-create-new-version @route="secret.details.edit" @type="add">Create new version</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</KvPageHeader>
|
||||
|
||||
{{#if (or @secret.deletionTime (not this.emptyState))}}
|
||||
<div class="info-table-row-header">
|
||||
<div class="info-table-row thead {{if this.showJsonView 'is-shadowless'}} ">
|
||||
{{#unless this.hideHeaders}}
|
||||
<div class="th column is-one-quarter">
|
||||
Key
|
||||
</div>
|
||||
<div class="th column">
|
||||
Value
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="th column justify-right">
|
||||
{{#if (or @secret.deletionTime @secret.createdTime)}}
|
||||
<KvTooltipTimestamp
|
||||
@text="Version {{if @secret.version @secret.version}} {{@secret.state}}"
|
||||
@timestamp={{or @secret.deletionTime @secret.createdTime}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.emptyState}}
|
||||
<EmptyState @title={{this.emptyState.title}} @message={{this.emptyState.message}}>
|
||||
{{#if this.emptyState.link}}
|
||||
<DocLink @path={{this.emptyState.link}}>Learn more</DocLink>
|
||||
{{/if}}
|
||||
</EmptyState>
|
||||
{{else}}
|
||||
{{#if this.showJsonView}}
|
||||
<JsonEditor @title="Version data" @value={{stringify @secret.secretData}} @readOnly={{true}} />
|
||||
{{else}}
|
||||
{{#each-in @secret.secretData as |key value|}}
|
||||
<InfoTableRow @label={{key}} @value={{value}} @alwaysRender={{true}}>
|
||||
<MaskedInput @name={{key}} @value={{value}} @displayOnly={{true}} @allowCopy={{true}} @allowDownload={{true}} />
|
||||
</InfoTableRow>
|
||||
{{else}}
|
||||
<InfoTableRow @label="" @value="" @alwaysRender={{true}} />
|
||||
{{/each-in}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
200
ui/lib/kv/addon/components/page/secret/details.js
Normal file
200
ui/lib/kv/addon/components/page/secret/details.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { next } from '@ember/runloop';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
|
||||
/**
|
||||
* @module KvSecretDetails renders the key/value data of a KV secret.
|
||||
* It also renders a dropdown to display different versions of the secret.
|
||||
* <Page::Secret::Details
|
||||
* @path={{this.model.path}}
|
||||
* @secret={{this.model.secret}}
|
||||
* @metadata={{this.model.metadata}}
|
||||
* @breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
*
|
||||
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
|
||||
* @param {model} secret - Ember data model: 'kv/data'
|
||||
* @param {model} metadata - Ember data model: 'kv/metadata'
|
||||
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
|
||||
*/
|
||||
|
||||
export default class KvSecretDetails extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
@tracked showJsonView = false;
|
||||
@tracked wrappedData = null;
|
||||
|
||||
@action
|
||||
toggleJsonView() {
|
||||
this.showJsonView = !this.showJsonView;
|
||||
}
|
||||
|
||||
@action
|
||||
closeVersionMenu(dropdown) {
|
||||
// strange issue where closing dropdown triggers full transition (which redirects to auth screen in production)
|
||||
// closing dropdown in next tick of run loop fixes it
|
||||
next(() => dropdown.actions.close());
|
||||
}
|
||||
|
||||
@action
|
||||
clearWrappedData() {
|
||||
this.wrappedData = null;
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*wrapSecret() {
|
||||
const { backend, path } = this.args.secret;
|
||||
const adapter = this.store.adapterFor('kv/data');
|
||||
try {
|
||||
const { token } = yield adapter.fetchWrapInfo({ backend, path, wrapTTL: 1800 });
|
||||
if (!token) throw 'No token';
|
||||
this.wrappedData = token;
|
||||
this.flashMessages.success('Secret successfully wrapped!');
|
||||
} catch (error) {
|
||||
this.flashMessages.danger('Could not wrap secret.');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async undelete() {
|
||||
const { secret } = this.args;
|
||||
try {
|
||||
await secret.destroyRecord({
|
||||
adapterOptions: { deleteType: 'undelete', deleteVersions: secret.version },
|
||||
});
|
||||
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
|
||||
this.flashMessages.success(`Successfully undeleted ${secret.path}.`);
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret', {
|
||||
queryParams: { version: secret.version },
|
||||
});
|
||||
} catch (err) {
|
||||
this.flashMessages.danger(
|
||||
`There was a problem undeleting ${secret.path}. Error: ${err.errors.join(' ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async handleDestruction(type) {
|
||||
const { secret } = this.args;
|
||||
try {
|
||||
await secret.destroyRecord({ adapterOptions: { deleteType: type, deleteVersions: secret.version } });
|
||||
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
|
||||
this.flashMessages.success(`Successfully ${secret.state} Version ${secret.version} of ${secret.path}.`);
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret', {
|
||||
queryParams: { version: secret.version },
|
||||
});
|
||||
} catch (err) {
|
||||
const verb = type.includes('delete') ? 'deleting' : 'destroying';
|
||||
this.flashMessages.danger(
|
||||
`There was a problem ${verb} Version ${secret.version} of ${secret.path}. Error: ${err.errors.join(
|
||||
' '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this.args.secret.version || this.router.currentRoute.queryParams.version;
|
||||
}
|
||||
|
||||
get hideHeaders() {
|
||||
return this.showJsonView || this.emptyState;
|
||||
}
|
||||
|
||||
get versionState() {
|
||||
const { secret, metadata } = this.args;
|
||||
if (secret.failReadErrorCode !== 403) {
|
||||
return secret.state;
|
||||
}
|
||||
// If the user can't read secret data, get the current version
|
||||
// state from metadata versions
|
||||
if (metadata?.sortedVersions) {
|
||||
const version = this.version;
|
||||
const meta = version
|
||||
? metadata.sortedVersions.find((v) => v.version == version)
|
||||
: metadata.sortedVersions[0];
|
||||
if (meta?.destroyed) {
|
||||
return 'destroyed';
|
||||
}
|
||||
if (meta?.deletion_time) {
|
||||
return 'deleted';
|
||||
}
|
||||
if (meta?.created_time) {
|
||||
return 'created';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
get showUndelete() {
|
||||
const { secret } = this.args;
|
||||
if (secret.canUndelete) {
|
||||
return this.versionState === 'deleted';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get showDelete() {
|
||||
const { secret } = this.args;
|
||||
if (secret.canDeleteVersion || secret.canDeleteLatestVersion) {
|
||||
return this.versionState === 'created';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get showDestroy() {
|
||||
const { secret } = this.args;
|
||||
if (secret.canDestroyVersion) {
|
||||
return this.versionState !== 'destroyed';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get emptyState() {
|
||||
if (!this.args.secret.canReadData) {
|
||||
return {
|
||||
title: 'You do not have permission to read this secret',
|
||||
message:
|
||||
'Your policies may permit you to write a new version of this secret, but do not allow you to read its current contents.',
|
||||
};
|
||||
}
|
||||
// only destructure if we can read secret data
|
||||
const { version, destroyed, deletionTime } = this.args.secret;
|
||||
if (destroyed) {
|
||||
return {
|
||||
title: `Version ${version} of this secret has been permanently destroyed`,
|
||||
message: `A version that has been permanently deleted cannot be restored. ${
|
||||
this.args.secret.canReadMetadata
|
||||
? ' You can view other versions of this secret in the Version History tab above.'
|
||||
: ''
|
||||
}`,
|
||||
link: '/vault/docs/secrets/kv/kv-v2',
|
||||
};
|
||||
}
|
||||
if (deletionTime) {
|
||||
return {
|
||||
title: `Version ${version} of this secret has been deleted`,
|
||||
message: `This version has been deleted but can be undeleted. ${
|
||||
this.args.secret.canReadMetadata
|
||||
? 'View other versions of this secret by clicking the Version History tab above.'
|
||||
: ''
|
||||
}`,
|
||||
link: '/vault/docs/secrets/kv/kv-v2',
|
||||
};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
72
ui/lib/kv/addon/components/page/secret/edit.hbs
Normal file
72
ui/lib/kv/addon/components/page/secret/edit.hbs
Normal file
@@ -0,0 +1,72 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Create New Version">
|
||||
<:toolbarFilters>
|
||||
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
|
||||
<span class="has-text-grey">JSON</span>
|
||||
</Toggle>
|
||||
</:toolbarFilters>
|
||||
</KvPageHeader>
|
||||
|
||||
{{#if this.showOldVersionAlert}}
|
||||
<Hds::Alert data-test-secret-version-alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description>
|
||||
You are creating a new version based on data from Version
|
||||
{{@previousVersion}}. The current version for
|
||||
<code>{{@secret.path}}</code>
|
||||
is Version
|
||||
{{@currentVersion}}.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
{{#if @noReadAccess}}
|
||||
<Hds::Alert data-test-secret-no-read-alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description>
|
||||
You do not have read permissions for this secret data. Saving will overwrite the existing secret.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
|
||||
<form {{on "submit" (perform this.save)}}>
|
||||
<div class="box is-sideless is-fullwidth is-bottomless">
|
||||
<NamespaceReminder @mode="create" @noun="secret" />
|
||||
<MessageError @model={{@secret}} @errorMessage={{this.errorMessage}} />
|
||||
|
||||
<KvDataFields
|
||||
@showJson={{this.showJsonView}}
|
||||
@secret={{@secret}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
@isEdit={{true}}
|
||||
/>
|
||||
</div>
|
||||
<div class="box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
|
||||
disabled={{this.save.isRunning}}
|
||||
data-test-kv-save
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.onCancel}}
|
||||
data-test-kv-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{{#if this.invalidFormAlert}}
|
||||
<AlertInline
|
||||
data-test-invalid-form-alert
|
||||
class="has-top-padding-s"
|
||||
@type="danger"
|
||||
@message={{this.invalidFormAlert}}
|
||||
@mimicRefresh={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
75
ui/lib/kv/addon/components/page/secret/edit.js
Normal file
75
ui/lib/kv/addon/components/page/secret/edit.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* @module KvSecretEdit is used for creating a new version of a secret
|
||||
*
|
||||
* <Page::Secret::Edit
|
||||
* @secret={{this.model.newVersion}}
|
||||
* @previousVersion={{this.model.secret.version}}
|
||||
* @currentVersion={{this.model.metadata.currentVersion}}
|
||||
* @breadcrumbs={{this.breadcrumbs}
|
||||
* />
|
||||
*
|
||||
* @param {model} secret - Ember data model: 'kv/data', the new record for the new secret version saved by the form
|
||||
* @param {number} previousVersion - previous secret version number
|
||||
* @param {number} currentVersion - current secret version, comes from the metadata endpoint
|
||||
* @param {array} breadcrumbs - breadcrumb objects to render in page header
|
||||
*/
|
||||
|
||||
export default class KvSecretEdit extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
|
||||
@tracked showJsonView = false;
|
||||
@tracked errorMessage;
|
||||
@tracked modelValidations;
|
||||
@tracked invalidFormAlert;
|
||||
|
||||
get showOldVersionAlert() {
|
||||
const { currentVersion, previousVersion } = this.args;
|
||||
// isNew check prevents alert from flashing after save but before route transitions
|
||||
if (!currentVersion || !previousVersion || !this.args.secret.isNew) return false;
|
||||
if (currentVersion !== previousVersion) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleJsonView() {
|
||||
this.showJsonView = !this.showJsonView;
|
||||
}
|
||||
|
||||
@task
|
||||
*save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const { isValid, state, invalidFormMessage } = this.args.secret.validate();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormAlert = invalidFormMessage;
|
||||
if (isValid) {
|
||||
const { secret } = this.args;
|
||||
yield this.args.secret.save();
|
||||
this.flashMessages.success(`Successfully created new version of ${secret.path}.`);
|
||||
// transition to parent secret route to re-query latest version
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.errors ? error.errors.join('. ') : error.message;
|
||||
this.errorMessage = message;
|
||||
this.invalidFormAlert = 'There was an error submitting this form.';
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onCancel() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details');
|
||||
}
|
||||
}
|
||||
72
ui/lib/kv/addon/components/page/secret/metadata/details.hbs
Normal file
72
ui/lib/kv/addon/components/page/secret/metadata/details.hbs
Normal file
@@ -0,0 +1,72 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
||||
<:tabLinks>
|
||||
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
|
||||
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
|
||||
{{#if @secret.canReadMetadata}}
|
||||
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
|
||||
{{/if}}
|
||||
</:tabLinks>
|
||||
|
||||
<:toolbarActions>
|
||||
{{#if @metadata.canDeleteMetadata}}
|
||||
<KvDeleteModal @mode="delete-metadata" @metadata={{@metadata}} @onDelete={{this.onDelete}}>
|
||||
Delete metadata
|
||||
</KvDeleteModal>
|
||||
{{/if}}
|
||||
{{#if @metadata.canUpdateMetadata}}
|
||||
<ToolbarLink @route="secret.metadata.edit" data-test-edit-metadata>Edit metadata</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</KvPageHeader>
|
||||
|
||||
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
|
||||
Custom metadata
|
||||
</h2>
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
|
||||
{{#if (or @metadata.canReadMetadata @secret.canReadData)}}
|
||||
{{#each-in (or @metadata.customMetadata @secret.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."
|
||||
>
|
||||
{{#if @metadata.canUpdateMetadata}}
|
||||
<LinkTo @route="secret.metadata.edit" @model={{@path}} data-test-add-custom-metadata>
|
||||
Add metadata
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</EmptyState>
|
||||
{{/each-in}}
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="You do not have access to read custom metadata"
|
||||
@message="In order to read custom metadata you either need read access to the secret data and/or read access to metadata."
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<section data-test-kv-metadata-section>
|
||||
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
|
||||
Secret metadata
|
||||
</h2>
|
||||
{{#if @metadata.canReadMetadata}}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
|
||||
<InfoTableRow @alwaysRender={{true}} @label="Last updated">
|
||||
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />
|
||||
</InfoTableRow>
|
||||
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{@metadata.maxVersions}} />
|
||||
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{@metadata.casRequired}} />
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label="Delete version after"
|
||||
@value={{if (eq @metadata.deleteVersionAfter "0s") "Never delete" (format-duration @metadata.deleteVersionAfter)}}
|
||||
/>
|
||||
</div>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="You do not have access to secret metadata"
|
||||
@message="In order to edit secret metadata, the UI requires read permissions; otherwise, data may be deleted. Edits can still be made via the API and CLI."
|
||||
/>
|
||||
{{/if}}
|
||||
</section>
|
||||
46
ui/lib/kv/addon/components/page/secret/metadata/details.js
Normal file
46
ui/lib/kv/addon/components/page/secret/metadata/details.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
/**
|
||||
* @module KvSecretMetadataDetails renders the details view for kv/metadata.
|
||||
* It also renders a button to delete metadata.
|
||||
* <Page::Secret::Metadata::Details
|
||||
* @path={{this.model.path}}
|
||||
* @secret={{this.model.secret}}
|
||||
* @metadata={{this.model.metadata}}
|
||||
* @breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
*
|
||||
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
|
||||
* @param {model} [secret] - Ember data model: 'kv/data'. Param not required for delete-metadata.
|
||||
* @param {model} metadata - Ember data model: 'kv/metadata'
|
||||
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
|
||||
*/
|
||||
|
||||
export default class KvSecretMetadataDetails extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
@action
|
||||
async onDelete() {
|
||||
// The only delete option from this view is delete on metadata.
|
||||
const { metadata } = this.args;
|
||||
try {
|
||||
await metadata.destroyRecord();
|
||||
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
|
||||
this.flashMessages.success(
|
||||
`Successfully deleted the metadata and all version data for the secret ${metadata.path}.`
|
||||
);
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
|
||||
} catch (err) {
|
||||
this.flashMessages.danger(`There was an issue deleting ${metadata.path} metadata.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
ui/lib/kv/addon/components/page/secret/metadata/edit.hbs
Normal file
57
ui/lib/kv/addon/components/page/secret/metadata/edit.hbs
Normal file
@@ -0,0 +1,57 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Edit Secret Metadata" />
|
||||
|
||||
<hr class="is-marginless has-background-gray-200" />
|
||||
<p class="has-top-margin-m has-bottom-margin-m">
|
||||
The options below are all version-agnostic; they apply to all versions of this secret.
|
||||
</p>
|
||||
{{#if @metadata.canUpdateMetadata}}
|
||||
<form {{on "submit" (perform this.save)}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<NamespaceReminder @mode="update" @noun="KV secret metadata" />
|
||||
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
|
||||
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="has-top-padding-s">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
|
||||
disabled={{this.save.isRunning}}
|
||||
data-test-kv-save
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
data-test-kv-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{{#if this.invalidFormAlert}}
|
||||
<div class="control">
|
||||
<AlertInline
|
||||
data-test-invalid-form-alert
|
||||
@type="danger"
|
||||
@paddingTop={{true}}
|
||||
@message={{this.invalidFormAlert}}
|
||||
@mimicRefresh={{true}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="You do not have permissions to edit metadata"
|
||||
@message="Ask your administrator if you think you should have access."
|
||||
>
|
||||
<LinkTo @route="secret.metadata.index" @model={{@metadata.path}}>
|
||||
View Metadata
|
||||
</LinkTo>
|
||||
<DocLink @path="/vault/api-docs/secret/kv/kv-v2#create-update-metadata">More here</DocLink>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
54
ui/lib/kv/addon/components/page/secret/metadata/edit.js
Normal file
54
ui/lib/kv/addon/components/page/secret/metadata/edit.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
/**
|
||||
* @module KvSecretMetadataEdit
|
||||
* This component renders the view for editing a kv secret's metadata.
|
||||
* While secret data and metadata are created on the same view, they are edited on different views/routes.
|
||||
*
|
||||
* @param {array} metadata - The kv/metadata model. It is version agnostic.
|
||||
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
|
||||
* @callback onCancel - Callback triggered when cancel button is clicked that transitions to the metadata details route.
|
||||
* @callback onSave - Callback triggered on save success that transitions to the metadata details route.
|
||||
*/
|
||||
|
||||
export default class KvSecretMetadataEditComponent extends Component {
|
||||
@service flashMessages;
|
||||
@tracked errorBanner = '';
|
||||
@tracked invalidFormAlert = '';
|
||||
@tracked modelValidations = null;
|
||||
|
||||
@action
|
||||
cancel() {
|
||||
this.args.metadata.rollbackAttributes();
|
||||
this.args.onCancel();
|
||||
}
|
||||
|
||||
@task
|
||||
*save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const { isValid, state, invalidFormMessage } = this.args.metadata.validate();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormAlert = invalidFormMessage;
|
||||
if (isValid) {
|
||||
const { path } = this.args.metadata;
|
||||
yield this.args.metadata.save();
|
||||
this.flashMessages.success(`Successfully updated ${path}'s metadata.`);
|
||||
this.args.onSave();
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorBanner = errorMessage(error);
|
||||
this.invalidFormAlert = 'There was an error submitting this form.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
||||
<:toolbarFilters>
|
||||
{{! left side version selector }}
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
|
||||
<D.Trigger data-test-version-dropdown-left class="toolbar-link {{if D.isOpen ' is-active'}}">
|
||||
Version
|
||||
{{this.leftSideVersion}}
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="popup-menu-content">
|
||||
<nav class="box menu" aria-label="version-left">
|
||||
<ul class="menu-list">
|
||||
{{#each @metadata.sortedVersions as |versionData|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="link {{if (loose-equal versionData.version this.leftSideVersion) 'is-active'}}"
|
||||
{{on "click" (fn this.selectVersion versionData.version D.actions "left")}}
|
||||
disabled={{(or versionData.destroyed versionData.deletion_time)}}
|
||||
>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.deletion_time}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal @metadata.currentVersion versionData.version)}}
|
||||
<Icon @name="check-circle-fill" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
{{! right side version selector }}
|
||||
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
|
||||
<D.Trigger data-test-version-dropdown-right class="toolbar-link {{if D.isOpen ' is-active'}}">
|
||||
Version
|
||||
{{this.rightSideVersion}}
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</D.Trigger>
|
||||
<D.Content @defaultClass="popup-menu-content">
|
||||
<nav class="box menu" aria-label="version-right">
|
||||
<ul class="menu-list">
|
||||
{{#each @metadata.sortedVersions as |versionData|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="link {{if (loose-equal versionData.version this.rightSideVersion) 'is-active'}}"
|
||||
{{on "click" (fn this.selectVersion versionData.version D.actions "right")}}
|
||||
disabled={{(or versionData.destroyed versionData.deletion_time)}}
|
||||
data-test-version-button={{versionData.version}}
|
||||
>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.deletion_time}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal @metadata.currentVersion versionData.version)}}
|
||||
<Icon @name="check-circle-fill" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</nav>
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
{{! status icon }}
|
||||
{{#if this.statesMatch}}
|
||||
<div class="has-left-padding-s">
|
||||
<Icon @name="check-circle-fill" class="has-text-success" />
|
||||
<span>States match</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
</KvPageHeader>
|
||||
{{! Show an empty state if the current version of the secret is deleted or destroyed. This would only happen on init. }}
|
||||
{{#if (and (loose-equal this.leftSideVersion @metadata.currentVersion) @metadata.currentSecret.isDeactivated)}}
|
||||
<EmptyState
|
||||
@title="Version {{@metadata.currentVersion}} of {{@path}} has been {{@metadata.currentSecret.state}}"
|
||||
@message="Please select another version of the secret to compare."
|
||||
/>
|
||||
{{else}}
|
||||
<div class="form-section visual-diff text-grey-lightest background-color-black">
|
||||
<pre data-test-visual-diff>{{sanitized-html this.visualDiff}}</pre>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { kvDataPath } from 'vault/utils/kv-path';
|
||||
|
||||
/**
|
||||
* @module KvVersionDiff
|
||||
* This component produces a JSON diff view between 2 secret versions. It uses the library jsondiffpatch.
|
||||
*
|
||||
* @param {string} backend - Backend from the kv/data model.
|
||||
* @param {string} path - Backend from the kv/data model.
|
||||
* @param {array} metadata - The kv/metadata model. It is version agnostic.
|
||||
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component.
|
||||
*/
|
||||
|
||||
export default class KvVersionDiffComponent extends Component {
|
||||
@service store;
|
||||
@tracked leftSideVersion;
|
||||
@tracked rightSideVersion;
|
||||
@tracked visualDiff;
|
||||
@tracked statesMatch = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
// tracked properties set here because they use args.
|
||||
this.leftSideVersion = this.args.metadata.currentVersion;
|
||||
this.rightSideVersion = this.defaultRightSideVersion;
|
||||
this.createVisualDiff();
|
||||
}
|
||||
|
||||
get defaultRightSideVersion() {
|
||||
// unless the version is destroyed or deleted we return the version prior to the current version.
|
||||
const versionData = this.args.metadata.sortedVersions.find(
|
||||
(version) =>
|
||||
version.destroyed === false && version.deletion_time === '' && version.version != this.leftSideVersion
|
||||
);
|
||||
return versionData ? versionData.version : this.leftSideVersion - 1;
|
||||
}
|
||||
|
||||
async fetchSecretData(version) {
|
||||
const { backend, path } = this.args;
|
||||
// check the store first, avoiding an extra network request if possible.
|
||||
const storeData = await this.store.peekRecord('kv/data', kvDataPath(backend, path, version));
|
||||
const data = storeData ? storeData : await this.store.queryRecord('kv/data', { backend, path, version });
|
||||
|
||||
return data?.secretData;
|
||||
}
|
||||
|
||||
async createVisualDiff() {
|
||||
/* eslint-disable no-undef */
|
||||
const leftSideData = await this.fetchSecretData(Number(this.leftSideVersion));
|
||||
const rightSideData = await this.fetchSecretData(Number(this.rightSideVersion));
|
||||
const diffpatcher = jsondiffpatch.create({});
|
||||
const delta = diffpatcher.diff(rightSideData, leftSideData);
|
||||
|
||||
this.statesMatch = !delta;
|
||||
this.visualDiff = delta
|
||||
? jsondiffpatch.formatters.html.format(delta, rightSideData)
|
||||
: JSON.stringify(leftSideData, undefined, 2);
|
||||
}
|
||||
|
||||
@action selectVersion(version, actions, side) {
|
||||
if (side === 'right') {
|
||||
this.rightSideVersion = version;
|
||||
}
|
||||
if (side === 'left') {
|
||||
this.leftSideVersion = version;
|
||||
}
|
||||
// close dropdown menu.
|
||||
if (actions) actions.close();
|
||||
this.createVisualDiff();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
||||
<:tabLinks>
|
||||
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
|
||||
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
|
||||
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
|
||||
</:tabLinks>
|
||||
</KvPageHeader>
|
||||
|
||||
<Toolbar />
|
||||
{{#if @metadata.canReadMetadata}}
|
||||
<div class="sub-text has-text-weight-semibold is-flex-end has-short-padding">
|
||||
<KvTooltipTimestamp @text="Secret last updated" @timestamp={{@metadata.updatedTime}} />
|
||||
</div>
|
||||
{{#each @metadata.sortedVersions as |versionData|}}
|
||||
<LinkedBlock
|
||||
class="list-item-row"
|
||||
@params={{array "vault.cluster.secrets.backend.kv.secret.details" @metadata.path}}
|
||||
@queryParams={{hash version=versionData.version}}
|
||||
data-test-version-linked-block={{versionData.version}}
|
||||
>
|
||||
<div class="level is-mobile">
|
||||
<div class="is-grid is-grid-3-columns is-three-fourths-width">
|
||||
{{! version number and icon }}
|
||||
<div class="align-self-center">
|
||||
<Icon @name="history" class="has-text-grey" data-test-version />
|
||||
<span class="has-text-weight-semibold has-text-black">
|
||||
Version
|
||||
{{versionData.version}}
|
||||
</span>
|
||||
</div>
|
||||
{{! icons }}
|
||||
<div class="align-self-center" data-test-icon-holder={{versionData.version}}>
|
||||
{{#if versionData.destroyed}}
|
||||
<div>
|
||||
<span class="has-text-danger is-size-8 is-block">
|
||||
<Icon @name="x-square-fill" />Destroyed
|
||||
</span>
|
||||
</div>
|
||||
{{else if versionData.deletion_time}}
|
||||
<div>
|
||||
<span class="has-text-grey is-size-8 is-block">
|
||||
<Icon @name="x-square-fill" />
|
||||
<KvTooltipTimestamp @text="Deleted" @timestamp={{versionData.deletion_time}} />
|
||||
</span>
|
||||
</div>
|
||||
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
|
||||
<div>
|
||||
<span class="has-text-success is-size-8 is-block">
|
||||
<Icon @name="check-circle-fill" />Current
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{! version created date }}
|
||||
<div class="is-size-8 has-text-weight-semibold has-text-grey align-self-center">
|
||||
<KvTooltipTimestamp @text="Created" @timestamp={{versionData.created_time}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<PopupMenu @name="version-{{versionData.version}}">
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<LinkTo @route="secret.details" @query={{hash version=versionData.version}}>
|
||||
View version
|
||||
{{versionData.version}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{#if @metadata.canCreateVersionData}}
|
||||
<li>
|
||||
<LinkTo
|
||||
@route="secret.details.edit"
|
||||
@query={{hash version=versionData.version}}
|
||||
data-test-create-new-version-from={{versionData.version}}
|
||||
@disabled={{or versionData.destroyed versionData.deletion_time}}
|
||||
>
|
||||
Create new version from
|
||||
{{versionData.version}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkedBlock>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="You do not have permission to read metadata"
|
||||
@message="Ask your administrator if you think you should have access."
|
||||
/>
|
||||
{{/if}}
|
||||
65
ui/lib/kv/addon/components/page/secrets/create.hbs
Normal file
65
ui/lib/kv/addon/components/page/secrets/create.hbs
Normal file
@@ -0,0 +1,65 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Create Secret">
|
||||
<:toolbarFilters>
|
||||
<Toggle @name="json" @status="success" @size="small" @checked={{this.showJsonView}} @onChange={{this.toggleJsonView}}>
|
||||
<span class="has-text-grey">JSON</span>
|
||||
</Toggle>
|
||||
</:toolbarFilters>
|
||||
</KvPageHeader>
|
||||
|
||||
<form {{on "submit" (perform this.save)}}>
|
||||
<div class="box is-sideless is-fullwidth is-bottomless">
|
||||
<NamespaceReminder @mode="create" @noun="secret" />
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
|
||||
<KvDataFields
|
||||
@showJson={{this.showJsonView}}
|
||||
@secret={{@secret}}
|
||||
@modelValidations={{this.modelValidations}}
|
||||
@pathValidations={{this.pathValidations}}
|
||||
/>
|
||||
|
||||
<ToggleButton
|
||||
@isOpen={{this.showMetadata}}
|
||||
@openLabel="Hide secret metadata"
|
||||
@closedLabel="Show secret metadata"
|
||||
@onClick={{fn (mut this.showMetadata)}}
|
||||
class="is-block"
|
||||
data-test-metadata-toggle
|
||||
/>
|
||||
{{#if this.showMetadata}}
|
||||
<div class="box has-container" data-test-metadata-section>
|
||||
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
|
||||
disabled={{this.save.isRunning}}
|
||||
data-test-kv-save
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.onCancel}}
|
||||
data-test-kv-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{{#if this.invalidFormAlert}}
|
||||
<AlertInline
|
||||
data-test-invalid-form-alert
|
||||
class="has-top-padding-s"
|
||||
@type="danger"
|
||||
@message={{this.invalidFormAlert}}
|
||||
@mimicRefresh={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
127
ui/lib/kv/addon/components/page/secrets/create.js
Normal file
127
ui/lib/kv/addon/components/page/secrets/create.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { pathIsFromDirectory } from 'kv/utils/kv-breadcrumbs';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
|
||||
/**
|
||||
* @module KvSecretCreate is used for creating the initial version of a secret
|
||||
*
|
||||
* <Page::Secrets::Create
|
||||
* @secret={{this.model.secret}}
|
||||
* @metadata={{this.model.metadata}}
|
||||
* @breadcrumbs={{this.breadcrumbs}}
|
||||
* />
|
||||
*
|
||||
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
|
||||
* @param {model} metadata - Ember data model: 'kv/metadata'
|
||||
* @param {array} breadcrumbs - breadcrumb objects to render in page header
|
||||
*/
|
||||
|
||||
export default class KvSecretCreate extends Component {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
@tracked showJsonView = false;
|
||||
@tracked errorMessage; // only renders for kv/data API errors
|
||||
@tracked modelValidations;
|
||||
@tracked invalidFormAlert;
|
||||
|
||||
@action
|
||||
toggleJsonView() {
|
||||
this.showJsonView = !this.showJsonView;
|
||||
}
|
||||
|
||||
@action
|
||||
pathValidations() {
|
||||
// check path attribute warnings on key up
|
||||
const { state } = this.args.secret.validate();
|
||||
if (state?.path?.warnings) {
|
||||
// only set model validations if warnings exist
|
||||
this.modelValidations = state;
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
*save(event) {
|
||||
event.preventDefault();
|
||||
this.resetErrors();
|
||||
|
||||
const { isValid, state } = this.validate();
|
||||
this.modelValidations = isValid ? null : state;
|
||||
this.invalidFormAlert = isValid ? '' : 'There is an error with this form.';
|
||||
|
||||
const { secret, metadata } = this.args;
|
||||
if (isValid) {
|
||||
try {
|
||||
// try saving secret data first
|
||||
yield secret.save();
|
||||
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
|
||||
this.flashMessages.success(`Successfully saved secret data for: ${secret.path}.`);
|
||||
} catch (error) {
|
||||
this.errorMessage = errorMessage(error);
|
||||
}
|
||||
|
||||
// users must have permission to create secret data to create metadata in the UI
|
||||
// only attempt to save metadata if secret data saves successfully and metadata is edited
|
||||
if (secret.createdTime && this.hasChanged(metadata)) {
|
||||
try {
|
||||
metadata.path = secret.path;
|
||||
yield metadata.save();
|
||||
this.flashMessages.success(`Successfully saved metadata.`);
|
||||
} catch (error) {
|
||||
this.flashMessages.danger(`Secret data was saved but metadata was not: ${errorMessage(error)}`, {
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// prevent transition if there are errors with secret data
|
||||
if (this.errorMessage) {
|
||||
this.invalidFormAlert = 'There was an error submitting this form.';
|
||||
} else {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', secret.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onCancel() {
|
||||
const { path } = this.args.secret;
|
||||
pathIsFromDirectory(path)
|
||||
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', path)
|
||||
: this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
|
||||
validate() {
|
||||
const dataValidations = this.args.secret.validate();
|
||||
const metadataValidations = this.args.metadata.validate();
|
||||
const state = { ...dataValidations.state, ...metadataValidations.state };
|
||||
const failed = !dataValidations.isValid || !metadataValidations.isValid;
|
||||
return { state, isValid: !failed };
|
||||
}
|
||||
|
||||
hasChanged(model) {
|
||||
const fieldName = model.formFields.map((attr) => attr.name);
|
||||
const changedAttrs = Object.keys(model.changedAttributes());
|
||||
// exclusively check if form field attributes have changed ('backend' and 'path' are passed to createRecord)
|
||||
return changedAttrs.any((attr) => fieldName.includes(attr));
|
||||
}
|
||||
|
||||
resetErrors() {
|
||||
this.flashMessages.clearMessages();
|
||||
this.errorMessage = null;
|
||||
this.modelValidations = null;
|
||||
this.invalidFormAlert = null;
|
||||
}
|
||||
}
|
||||
10
ui/lib/kv/addon/controllers/create.js
Normal file
10
ui/lib/kv/addon/controllers/create.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class KvCreateController extends Controller {
|
||||
queryParams = ['initialKey'];
|
||||
}
|
||||
10
ui/lib/kv/addon/controllers/list.js
Normal file
10
ui/lib/kv/addon/controllers/list.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class KvListController extends Controller {
|
||||
queryParams = ['pageFilter', 'currentPage'];
|
||||
}
|
||||
6
ui/lib/kv/addon/controllers/secret/details.js
Normal file
6
ui/lib/kv/addon/controllers/secret/details.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class KvSecretDetailsController extends Controller {
|
||||
queryParams = ['version'];
|
||||
version = null;
|
||||
}
|
||||
24
ui/lib/kv/addon/engine.js
Normal file
24
ui/lib/kv/addon/engine.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Engine from '@ember/engine';
|
||||
|
||||
import loadInitializers from 'ember-load-initializers';
|
||||
import Resolver from 'ember-resolver';
|
||||
|
||||
import config from './config/environment';
|
||||
|
||||
const { modulePrefix } = config;
|
||||
|
||||
export default class KvEngine extends Engine {
|
||||
modulePrefix = modulePrefix;
|
||||
Resolver = Resolver;
|
||||
dependencies = {
|
||||
services: ['download', 'namespace', 'router', 'store', 'secret-mount-path', 'flash-messages'],
|
||||
externalRoutes: ['secrets'],
|
||||
};
|
||||
}
|
||||
|
||||
loadInitializers(KvEngine, modulePrefix);
|
||||
25
ui/lib/kv/addon/routes.js
Normal file
25
ui/lib/kv/addon/routes.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import buildRoutes from 'ember-engines/routes';
|
||||
|
||||
export default buildRoutes(function () {
|
||||
// There are two list routes because Ember won't let a route param (e.g. *path_to_secret) be blank.
|
||||
// :path_to_secret is used when we're listing a secret directory. Example { path: '/:beep%2Fboop%2F/directory' });
|
||||
this.route('list');
|
||||
this.route('list-directory', { path: '/:path_to_secret/directory' });
|
||||
this.route('create');
|
||||
this.route('secret', { path: '/:name' }, function () {
|
||||
this.route('details', function () {
|
||||
this.route('edit'); // route to create new version of a secret
|
||||
});
|
||||
this.route('metadata', function () {
|
||||
this.route('edit');
|
||||
this.route('versions');
|
||||
this.route('diff');
|
||||
});
|
||||
});
|
||||
this.route('configuration');
|
||||
});
|
||||
32
ui/lib/kv/addon/routes/configuration.js
Normal file
32
ui/lib/kv/addon/routes/configuration.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class KvConfigurationRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
const backend = this.modelFor('application');
|
||||
return hash({
|
||||
mountConfig: backend,
|
||||
engineConfig: this.store.findRecord('kv/config', backend.id).catch(() => {
|
||||
// return an empty record so we have access to model capabilities
|
||||
return this.store.createRecord('kv/config', { backend: backend.id });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.mountConfig.id, route: 'list' },
|
||||
{ label: 'configuration' },
|
||||
];
|
||||
}
|
||||
}
|
||||
41
ui/lib/kv/addon/routes/create.js
Normal file
41
ui/lib/kv/addon/routes/create.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { withConfirmLeave } from 'core/decorators/confirm-leave';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
@withConfirmLeave('model.secret', ['model.metadata'])
|
||||
export default class KvSecretsCreateRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
|
||||
model(params) {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { initialKey: path } = params;
|
||||
|
||||
return hash({
|
||||
backend,
|
||||
path,
|
||||
// see serializer for logic behind setting casVersion
|
||||
secret: this.store.createRecord('kv/data', { backend, path, casVersion: 0 }),
|
||||
metadata: this.store.createRecord('kv/metadata', { backend, path }),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
|
||||
const crumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'create' },
|
||||
];
|
||||
controller.breadcrumbs = crumbs;
|
||||
}
|
||||
}
|
||||
20
ui/lib/kv/addon/routes/error.js
Normal file
20
ui/lib/kv/addon/routes/error.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class KvErrorRoute extends Route {
|
||||
@service secretMountPath;
|
||||
|
||||
setupController(controller) {
|
||||
super.setupController(...arguments);
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.secretMountPath.currentPath, route: 'list' },
|
||||
];
|
||||
controller.mountName = this.secretMountPath.currentPath;
|
||||
}
|
||||
}
|
||||
89
ui/lib/kv/addon/routes/list-directory.js
Normal file
89
ui/lib/kv/addon/routes/list-directory.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { normalizePath } from 'vault/utils/path-encoding-helpers';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretsListRoute extends Route {
|
||||
@service store;
|
||||
@service router;
|
||||
@service secretMountPath;
|
||||
|
||||
queryParams = {
|
||||
pageFilter: {
|
||||
refreshModel: true,
|
||||
},
|
||||
currentPage: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
async fetchMetadata(backend, pathToSecret, params) {
|
||||
return await this.store
|
||||
.lazyPaginatedQuery('kv/metadata', {
|
||||
backend,
|
||||
responsePath: 'data.keys',
|
||||
page: Number(params.currentPage) || 1,
|
||||
size: Number(params.currentPageSize),
|
||||
pageFilter: params.pageFilter,
|
||||
pathToSecret,
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.httpStatus === 403) {
|
||||
return 403;
|
||||
}
|
||||
if (err.httpStatus === 404) {
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
model(params) {
|
||||
const { pageFilter, path_to_secret } = params;
|
||||
const pathToSecret = path_to_secret ? normalizePath(path_to_secret) : '';
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const filterValue = pathToSecret ? (pageFilter ? pathToSecret + pageFilter : pathToSecret) : pageFilter;
|
||||
return hash({
|
||||
secrets: this.fetchMetadata(backend, pathToSecret, params),
|
||||
backend,
|
||||
pathToSecret,
|
||||
filterValue,
|
||||
pageFilter,
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
if (resolvedModel.secrets === 403) {
|
||||
resolvedModel.noMetadataListPermissions = true;
|
||||
}
|
||||
|
||||
let breadcrumbsArray = [{ label: 'secrets', route: 'secrets', linkExternal: true }];
|
||||
// if on top level don't link the engine breadcrumb label, but if within a directory, do link back to top level.
|
||||
if (this.routeName === 'list') {
|
||||
breadcrumbsArray.push({ label: resolvedModel.backend });
|
||||
} else {
|
||||
breadcrumbsArray = [
|
||||
...breadcrumbsArray,
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.pathToSecret, true),
|
||||
];
|
||||
}
|
||||
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.set('pageFilter', null);
|
||||
controller.set('currentPage', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ui/lib/kv/addon/routes/list.js
Normal file
6
ui/lib/kv/addon/routes/list.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
export { default } from './list-directory';
|
||||
35
ui/lib/kv/addon/routes/secret.js
Normal file
35
ui/lib/kv/addon/routes/secret.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class KvSecretRoute extends Route {
|
||||
@service secretMountPath;
|
||||
@service store;
|
||||
|
||||
fetchSecretData(backend, path) {
|
||||
// This will always return a record unless 404 not found (show error) or control group
|
||||
return this.store.queryRecord('kv/data', { backend, path });
|
||||
}
|
||||
|
||||
fetchSecretMetadata(backend, path) {
|
||||
// catch error and do nothing because kv/data model handles metadata capabilities
|
||||
return this.store.queryRecord('kv/metadata', { backend, path }).catch(() => {});
|
||||
}
|
||||
|
||||
model() {
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const { name: path } = this.paramsFor('secret');
|
||||
|
||||
return hash({
|
||||
path,
|
||||
backend,
|
||||
secret: this.fetchSecretData(backend, path),
|
||||
metadata: this.fetchSecretMetadata(backend, path),
|
||||
});
|
||||
}
|
||||
}
|
||||
50
ui/lib/kv/addon/routes/secret/details.js
Normal file
50
ui/lib/kv/addon/routes/secret/details.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class KvSecretDetailsRoute extends Route {
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
version: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
model(params) {
|
||||
const parentModel = this.modelFor('secret');
|
||||
// Only fetch versioned data if selected version does not match parent (current) version
|
||||
// and parentModel.secret has failReadErrorCode since permissions aren't version specific
|
||||
if (
|
||||
params.version &&
|
||||
parentModel.secret.version !== params.version &&
|
||||
!parentModel.secret.failReadErrorCode
|
||||
) {
|
||||
// query params have changed by selecting a different version from the dropdown
|
||||
// fire off new request for that version's secret data
|
||||
const { backend, path } = parentModel;
|
||||
return hash({
|
||||
...parentModel,
|
||||
secret: this.store.queryRecord('kv/data', { backend, path, version: params.version }),
|
||||
});
|
||||
}
|
||||
return parentModel;
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const { version } = this.paramsFor(this.routeName);
|
||||
controller.set('version', resolvedModel.secret.version || version);
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.set('version', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ui/lib/kv/addon/routes/secret/details/edit.js
Normal file
39
ui/lib/kv/addon/routes/secret/details/edit.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { withConfirmLeave } from 'core/decorators/confirm-leave';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
@withConfirmLeave('model.newVersion')
|
||||
export default class KvSecretDetailsEditRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
const parentModel = this.modelFor('secret.details');
|
||||
const { backend, path, secret, metadata } = parentModel;
|
||||
return hash({
|
||||
secret,
|
||||
metadata,
|
||||
backend,
|
||||
path,
|
||||
newVersion: this.store.createRecord('kv/data', {
|
||||
backend,
|
||||
path,
|
||||
secretData: secret?.secretData,
|
||||
// see serializer for logic behind setting casVersion
|
||||
casVersion: metadata?.currentVersion || secret?.version,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'edit' },
|
||||
];
|
||||
}
|
||||
}
|
||||
21
ui/lib/kv/addon/routes/secret/details/index.js
Normal file
21
ui/lib/kv/addon/routes/secret/details/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretDetailsIndexRoute extends Route {
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path, true),
|
||||
];
|
||||
|
||||
controller.breadcrumbs = breadcrumbsArray;
|
||||
}
|
||||
}
|
||||
15
ui/lib/kv/addon/routes/secret/index.js
Normal file
15
ui/lib/kv/addon/routes/secret/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class SecretIndex extends Route {
|
||||
@service router;
|
||||
|
||||
redirect() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details');
|
||||
}
|
||||
}
|
||||
22
ui/lib/kv/addon/routes/secret/metadata/diff.js
Normal file
22
ui/lib/kv/addon/routes/secret/metadata/diff.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretMetadataDiffRoute extends Route {
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'version diff' },
|
||||
];
|
||||
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
}
|
||||
25
ui/lib/kv/addon/routes/secret/metadata/edit.js
Normal file
25
ui/lib/kv/addon/routes/secret/metadata/edit.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretMetadataEditRoute extends Route {
|
||||
// model passed from 'secret' route, if we need to access or intercept
|
||||
// it can retrieved via `this.modelFor('secret'), which includes the metadata model.
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'metadata', route: 'secret.metadata' },
|
||||
{ label: 'edit' },
|
||||
];
|
||||
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
}
|
||||
24
ui/lib/kv/addon/routes/secret/metadata/index.js
Normal file
24
ui/lib/kv/addon/routes/secret/metadata/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretMetadataIndexRoute extends Route {
|
||||
// model passed from parent secret route, if we need to access or intercept
|
||||
// it can retrieved via `this.modelFor('secret'), which includes the metadata model.
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'metadata' },
|
||||
];
|
||||
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
}
|
||||
21
ui/lib/kv/addon/routes/secret/metadata/versions.js
Normal file
21
ui/lib/kv/addon/routes/secret/metadata/versions.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretMetadataVersionsRoute extends Route {
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'version history' },
|
||||
];
|
||||
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user