mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-31 02:28:09 +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
	 Angel Garbarino
					Angel Garbarino