mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-31 18:48:08 +00:00 
			
		
		
		
	UI: add copyable paths for CLI and API commands to kv v2 (#22551)
* add paths route * WIP copy secret path component * wip component * ad v1 * use each-in to iterate over info table row * update copy * add commands to kv paths page * add comments * WIP tests * finish tests * remove version, address comments and use path arg directly remove secret * update copy * fix typo for perms * remove destructuring, that was confusing * add changelog * add secure protocal
This commit is contained in:
		
							
								
								
									
										3
									
								
								changelog/22551.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/22551.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ```release-note:feature | ||||
| **Copyable KV v2 paths in UI**: KV v2 secret paths are copyable for use in CLI commands or API calls | ||||
| ``` | ||||
| @@ -3,7 +3,7 @@ | ||||
|   SPDX-License-Identifier: BUSL-1.1 | ||||
| ~}} | ||||
|  | ||||
| <div class="code-snippet-container"> | ||||
| <div data-test-code-snippet class="code-snippet-container" ...attributes> | ||||
|   <code class="text-grey-lightest"> | ||||
|     {{@codeBlock}} | ||||
|   </code> | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
| {{#if (or (has-block) this.isVisible)}} | ||||
|   <div class="info-table-row" data-test-component="info-table-row" ...attributes> | ||||
|     <div | ||||
|       class="column is-one-quarter {{if this.hasLabelOverflow 'label-overflow'}}" | ||||
|       class="column {{or @labelWidth 'is-one-quarter'}} {{if this.hasLabelOverflow 'label-overflow'}}" | ||||
|       data-test-label-div | ||||
|       {{did-insert this.calculateLabelOverflow}} | ||||
|     > | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|   <: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.paths" data-test-secrets-tab="Paths">Paths</LinkTo> | ||||
|     {{#if @secret.canReadMetadata}} | ||||
|       <LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo> | ||||
|     {{/if}} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|   <: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.paths" data-test-secrets-tab="Paths">Paths</LinkTo> | ||||
|     {{#if @secret.canReadMetadata}} | ||||
|       <LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo> | ||||
|     {{/if}} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|   <: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.paths" data-test-secrets-tab="Paths">Paths</LinkTo> | ||||
|     <LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo> | ||||
|   </:tabLinks> | ||||
| </KvPageHeader> | ||||
|   | ||||
							
								
								
									
										63
									
								
								ui/lib/kv/addon/components/page/secret/paths.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								ui/lib/kv/addon/components/page/secret/paths.hbs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <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.paths" data-test-secrets-tab="Paths">Paths</LinkTo> | ||||
|     {{#if @canReadMetadata}} | ||||
|       <LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo> | ||||
|     {{/if}} | ||||
|   </:tabLinks> | ||||
| </KvPageHeader> | ||||
|  | ||||
| <h2 class="title is-5 has-top-margin-xl"> | ||||
|   Paths | ||||
| </h2> | ||||
|  | ||||
| <div class="box is-fullwidth is-sideless is-paddingless is-marginless"> | ||||
|   {{#each this.paths as |path|}} | ||||
|     <InfoTableRow @label={{path.label}} @labelWidth="is-one-third" @helperText={{path.text}}> | ||||
|       {{! replace with Hds::Copy::Snippet }} | ||||
|       <CopyButton | ||||
|         class="button is-compact is-transparent level-right" | ||||
|         @clipboardText={{path.snippet}} | ||||
|         @buttonType="button" | ||||
|         @success={{fn (set-flash-message (concat path.label " copied!"))}} | ||||
|       > | ||||
|         <Icon @name="clipboard-copy" aria-label="Copy" /> | ||||
|       </CopyButton> | ||||
|       <code class="has-left-margin-s level-left"> | ||||
|         {{path.snippet}} | ||||
|       </code> | ||||
|     </InfoTableRow> | ||||
|   {{/each}} | ||||
| </div> | ||||
|  | ||||
| <h2 class="title is-5 has-top-margin-xl"> | ||||
|   Commands | ||||
| </h2> | ||||
|  | ||||
| <div class="box is-fullwidth is-sideless"> | ||||
|   <h3 class="is-label"> | ||||
|     CLI | ||||
|     <Hds::Badge @text="kv get" @color="neutral" /> | ||||
|   </h3> | ||||
|   <p class="helper-text has-text-grey has-bottom-padding-s"> | ||||
|     This command retrieves the value from KV secrets engine at the given key name. For other CLI commands, | ||||
|     <DocLink @path="/vault/docs/commands/kv"> | ||||
|       learn more. | ||||
|     </DocLink> | ||||
|   </p> | ||||
|   <CodeSnippet data-test-commands="cli" @codeBlock={{this.commands.cli}} /> | ||||
|  | ||||
|   <h3 class="has-top-margin-l is-label"> | ||||
|     API read secret version | ||||
|   </h3> | ||||
|   <p class="helper-text has-text-grey has-bottom-padding-s"> | ||||
|     This command obtains data and metadata for the latest version of this secret. In this example, Vault is located at | ||||
|     https://127.0.0.1:8200. For other API commands, | ||||
|     <DocLink @path="/vault/api-docs/secret/kv/kv-v2"> | ||||
|       learn more. | ||||
|     </DocLink> | ||||
|   </p> | ||||
|   <CodeSnippet data-test-commands="api" @clipboardCode={{this.commands.apiCopy}} @codeBlock={{this.commands.apiDisplay}} /> | ||||
| </div> | ||||
							
								
								
									
										72
									
								
								ui/lib/kv/addon/components/page/secret/paths.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								ui/lib/kv/addon/components/page/secret/paths.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| /** | ||||
|  * Copyright (c) HashiCorp, Inc. | ||||
|  * SPDX-License-Identifier: MPL-2.0 | ||||
|  */ | ||||
|  | ||||
| import Component from '@glimmer/component'; | ||||
| import { inject as service } from '@ember/service'; | ||||
| import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path'; | ||||
|  | ||||
| /** | ||||
|  * @module KvSecretPaths is used to display copyable secret paths for KV v2 for CLI and API use. | ||||
|  * This view is permission agnostic because args come from the views mount path and url params. | ||||
|  * | ||||
|  * <Page::Secret::Paths | ||||
|  *  @path={{this.model.path}} | ||||
|  *  @backend={{this.model.backend}} | ||||
|  *  @breadcrumbs={{this.breadcrumbs}} | ||||
|  *  @canReadMetadata={{this.model.secret.canReadMetadata}} | ||||
|  * /> | ||||
|  * | ||||
|  * @param {string} path - kv secret path for building the CLI and API paths | ||||
|  * @param {string} backend - the secret engine mount path, comes from the secretMountPath service defined in the route | ||||
|  * @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component | ||||
|  * @param {boolean} [canReadMetadata=true] - if true, displays tab for Version History | ||||
|  */ | ||||
|  | ||||
| export default class KvSecretPaths extends Component { | ||||
|   @service namespace; | ||||
|  | ||||
|   get paths() { | ||||
|     const { backend, path } = this.args; | ||||
|     const namespace = this.namespace.path; | ||||
|     const cli = `-mount="${backend}" "${path}"`; | ||||
|     const data = kvDataPath(backend, path); | ||||
|     const metadata = kvMetadataPath(backend, path); | ||||
|  | ||||
|     return [ | ||||
|       { | ||||
|         label: 'API path', | ||||
|         snippet: namespace ? `/v1/${namespace}/${data}` : `/v1/${data}`, | ||||
|         text: 'Use this path when referring to this secret in the API.', | ||||
|       }, | ||||
|       { | ||||
|         label: 'CLI path', | ||||
|         snippet: namespace ? `-namespace=${namespace} ${cli}` : cli, | ||||
|         text: 'Use this path when referring to this secret in the CLI.', | ||||
|       }, | ||||
|       { | ||||
|         label: 'API path for metadata', | ||||
|         snippet: namespace ? `/v1/${namespace}/${metadata}` : `/v1/${metadata}`, | ||||
|         text: `Use this path when referring to this secret's metadata in the API and permanent secret deletion.`, | ||||
|       }, | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   get commands() { | ||||
|     const cliPath = this.paths.findBy('label', 'CLI path').snippet; | ||||
|     const apiPath = this.paths.findBy('label', 'API path').snippet; | ||||
|     // as a future improvement, it might be nice to use window.location.protocol here: | ||||
|     const url = `https://127.0.0.1:8200${apiPath}`; | ||||
|  | ||||
|     return { | ||||
|       cli: `vault kv get ${cliPath}`, | ||||
|       /* eslint-disable-next-line no-useless-escape */ | ||||
|       apiCopy: `curl \ --header "X-Vault-Token: ..." \ --request GET \ ${url}`, | ||||
|       apiDisplay: `curl \\ | ||||
|         --header "X-Vault-Token: ..." \\ | ||||
|         --request GET \\ | ||||
|       ${url}`, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
| @@ -12,6 +12,7 @@ export default buildRoutes(function () { | ||||
|   this.route('list-directory', { path: '/:path_to_secret/directory' }); | ||||
|   this.route('create'); | ||||
|   this.route('secret', { path: '/:name' }, function () { | ||||
|     this.route('paths'); | ||||
|     this.route('details', function () { | ||||
|       this.route('edit'); // route to create new version of a secret | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										20
									
								
								ui/lib/kv/addon/routes/secret/paths.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								ui/lib/kv/addon/routes/secret/paths.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| /** | ||||
|  * 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 KvSecretPathsRoute extends Route { | ||||
|   setupController(controller, resolvedModel) { | ||||
|     super.setupController(controller, resolvedModel); | ||||
|  | ||||
|     controller.breadcrumbs = [ | ||||
|       { label: 'secrets', route: 'secrets', linkExternal: true }, | ||||
|       { label: resolvedModel.backend, route: 'list' }, | ||||
|       ...breadcrumbsForSecret(resolvedModel.path), | ||||
|       { label: 'paths' }, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								ui/lib/kv/addon/templates/secret/paths.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								ui/lib/kv/addon/templates/secret/paths.hbs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <Page::Secret::Paths | ||||
|   @path={{this.model.path}} | ||||
|   @backend={{this.model.backend}} | ||||
|   @breadcrumbs={{this.breadcrumbs}} | ||||
|   @canReadMetadata={{this.model.secret.canReadMetadata}} | ||||
| /> | ||||
| @@ -59,6 +59,11 @@ export const PAGE = { | ||||
|   create: { | ||||
|     metadataSection: '[data-test-metadata-section]', | ||||
|   }, | ||||
|   paths: { | ||||
|     copyButton: (label) => `${PAGE.infoRowValue(label)} button`, | ||||
|     codeSnippet: (section) => `[data-test-code-snippet][data-test-commands="${section}"] code`, | ||||
|     snippetCopy: (section) => `[data-test-code-snippet][data-test-commands="${section}"] button`, | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| // Form/Interactive selectors that are common between pages and forms | ||||
|   | ||||
| @@ -0,0 +1,148 @@ | ||||
| /** | ||||
|  * Copyright (c) HashiCorp, Inc. | ||||
|  * SPDX-License-Identifier: MPL-2.0 | ||||
|  */ | ||||
|  | ||||
| import { module, test } from 'qunit'; | ||||
| import { setupRenderingTest } from 'ember-qunit'; | ||||
| import { setupEngine } from 'ember-engines/test-support'; | ||||
| import { render } from '@ember/test-helpers'; | ||||
| import { hbs } from 'ember-cli-htmlbars'; | ||||
| import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; | ||||
| /* eslint-disable no-useless-escape */ | ||||
|  | ||||
| module('Integration | Component | kv-v2 | Page::Secret::Paths', function (hooks) { | ||||
|   setupRenderingTest(hooks); | ||||
|   setupEngine(hooks, 'kv'); | ||||
|  | ||||
|   hooks.beforeEach(async function () { | ||||
|     this.backend = 'kv-engine'; | ||||
|     this.path = 'my-secret'; | ||||
|     this.breadcrumbs = [ | ||||
|       { label: 'secrets', route: 'secrets', linkExternal: true }, | ||||
|       { label: this.backend, route: 'list' }, | ||||
|       { label: this.path }, | ||||
|     ]; | ||||
|  | ||||
|     this.assertClipboard = (assert, element, expected) => { | ||||
|       assert.dom(element).hasAttribute('data-clipboard-text', expected); | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   test('it renders copyable paths', async function (assert) { | ||||
|     assert.expect(6); | ||||
|  | ||||
|     const paths = [ | ||||
|       { label: 'API path', expected: `/v1/${this.backend}/data/${this.path}` }, | ||||
|       { label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` }, | ||||
|       { label: 'API path for metadata', expected: `/v1/${this.backend}/metadata/${this.path}` }, | ||||
|     ]; | ||||
|  | ||||
|     await render( | ||||
|       hbs` | ||||
| <Page::Secret::Paths | ||||
|   @path={{this.path}} | ||||
|   @backend={{this.backend}} | ||||
|   @breadcrumbs={{this.breadcrumbs}} | ||||
| /> | ||||
|       `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|  | ||||
|     for (const path of paths) { | ||||
|       assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected); | ||||
|       this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   test('it renders copyable encoded mount and secret paths', async function (assert) { | ||||
|     assert.expect(6); | ||||
|     this.path = `my spacey!"secret`; | ||||
|     this.backend = `my fancy!"backend`; | ||||
|     const backend = encodeURIComponent(this.backend); | ||||
|     const path = encodeURIComponent(this.path); | ||||
|     const paths = [ | ||||
|       { | ||||
|         label: 'API path', | ||||
|         expected: `/v1/${backend}/data/${path}`, | ||||
|       }, | ||||
|       { label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` }, | ||||
|       { | ||||
|         label: 'API path for metadata', | ||||
|         expected: `/v1/${backend}/metadata/${path}`, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     await render( | ||||
|       hbs` | ||||
| <Page::Secret::Paths | ||||
|   @path={{this.path}} | ||||
|   @backend={{this.backend}} | ||||
|   @breadcrumbs={{this.breadcrumbs}} | ||||
| /> | ||||
|       `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|  | ||||
|     for (const path of paths) { | ||||
|       assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected); | ||||
|       this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   test('it renders copyable commands', async function (assert) { | ||||
|     assert.expect(4); | ||||
|     const url = `https://127.0.0.1:8200/v1/${this.backend}/data/${this.path}`; | ||||
|     const expected = { | ||||
|       cli: `vault kv get -mount="${this.backend}" "${this.path}"`, | ||||
|       apiDisplay: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`, | ||||
|       apiCopy: `curl  --header \"X-Vault-Token: ...\"  --request GET \ ${url}`, | ||||
|     }; | ||||
|     await render( | ||||
|       hbs` | ||||
| <Page::Secret::Paths | ||||
|   @path={{this.path}} | ||||
|   @backend={{this.backend}} | ||||
|   @breadcrumbs={{this.breadcrumbs}} | ||||
| /> | ||||
|       `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|  | ||||
|     assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli); | ||||
|     assert.dom(PAGE.paths.snippetCopy('cli')).hasAttribute('data-clipboard-text', expected.cli); | ||||
|     assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.apiDisplay); | ||||
|     assert.dom(PAGE.paths.snippetCopy('api')).hasAttribute('data-clipboard-text', expected.apiCopy); | ||||
|   }); | ||||
|  | ||||
|   test('it renders copyable encoded mount and path commands', async function (assert) { | ||||
|     assert.expect(4); | ||||
|     this.path = `my spacey!"secret`; | ||||
|     this.backend = `my fancy!"backend`; | ||||
|  | ||||
|     const backend = encodeURIComponent(this.backend); | ||||
|     const path = encodeURIComponent(this.path); | ||||
|     const url = `https://127.0.0.1:8200/v1/${backend}/data/${path}`; | ||||
|  | ||||
|     const expected = { | ||||
|       cli: `vault kv get -mount="${this.backend}" "${this.path}"`, | ||||
|       apiDisplay: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`, | ||||
|       apiCopy: `curl  --header \"X-Vault-Token: ...\"  --request GET \ ${url}`, | ||||
|     }; | ||||
|     await render( | ||||
|       hbs` | ||||
| <Page::Secret::Paths | ||||
|   @path={{this.path}} | ||||
|   @backend={{this.backend}} | ||||
|   @breadcrumbs={{this.breadcrumbs}} | ||||
| /> | ||||
|       `, | ||||
|       { owner: this.engine } | ||||
|     ); | ||||
|  | ||||
|     assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli); | ||||
|     assert.dom(PAGE.paths.snippetCopy('cli')).hasAttribute('data-clipboard-text', expected.cli); | ||||
|     assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.apiDisplay); | ||||
|     assert.dom(PAGE.paths.snippetCopy('api')).hasAttribute('data-clipboard-text', expected.apiCopy); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user
	 claire bontempo
					claire bontempo