diff --git a/ui/lib/kv/addon/components/page/secret/details.hbs b/ui/lib/kv/addon/components/page/secret/details.hbs
index 440aa4243e..23fd323c5b 100644
--- a/ui/lib/kv/addon/components/page/secret/details.hbs
+++ b/ui/lib/kv/addon/components/page/secret/details.hbs
@@ -2,6 +2,7 @@
<:tabLinks>
Secret
Metadata
+
Paths
{{#if @secret.canReadMetadata}}
Version History
{{/if}}
diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs
index 803449c80c..47d3338f1e 100644
--- a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs
+++ b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs
@@ -2,6 +2,7 @@
<:tabLinks>
Secret
Metadata
+
Paths
{{#if @secret.canReadMetadata}}
Version History
{{/if}}
diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs
index f20379758e..03912fefa8 100644
--- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs
+++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs
@@ -2,6 +2,7 @@
<:tabLinks>
Secret
Metadata
+
Paths
Version History
diff --git a/ui/lib/kv/addon/components/page/secret/paths.hbs b/ui/lib/kv/addon/components/page/secret/paths.hbs
new file mode 100644
index 0000000000..f5273ea2ea
--- /dev/null
+++ b/ui/lib/kv/addon/components/page/secret/paths.hbs
@@ -0,0 +1,63 @@
+
+ <:tabLinks>
+ Secret
+ Metadata
+ Paths
+ {{#if @canReadMetadata}}
+ Version History
+ {{/if}}
+
+
+
+
+ Paths
+
+
+
+ {{#each this.paths as |path|}}
+
+ {{! replace with Hds::Copy::Snippet }}
+
+
+
+
+ {{path.snippet}}
+
+
+ {{/each}}
+
+
+
+ Commands
+
+
+
+
+ CLI
+
+
+
+ This command retrieves the value from KV secrets engine at the given key name. For other CLI commands,
+
+ learn more.
+
+
+
+
+
+ API read secret version
+
+
+ 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,
+
+ learn more.
+
+
+
+
\ No newline at end of file
diff --git a/ui/lib/kv/addon/components/page/secret/paths.js b/ui/lib/kv/addon/components/page/secret/paths.js
new file mode 100644
index 0000000000..9a68d3da6b
--- /dev/null
+++ b/ui/lib/kv/addon/components/page/secret/paths.js
@@ -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.
+ *
+ *
+ *
+ * @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}`,
+ };
+ }
+}
diff --git a/ui/lib/kv/addon/routes.js b/ui/lib/kv/addon/routes.js
index e356c0031b..529ecbe8d5 100644
--- a/ui/lib/kv/addon/routes.js
+++ b/ui/lib/kv/addon/routes.js
@@ -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
});
diff --git a/ui/lib/kv/addon/routes/secret/paths.js b/ui/lib/kv/addon/routes/secret/paths.js
new file mode 100644
index 0000000000..a42eb8edeb
--- /dev/null
+++ b/ui/lib/kv/addon/routes/secret/paths.js
@@ -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' },
+ ];
+ }
+}
diff --git a/ui/lib/kv/addon/templates/secret/paths.hbs b/ui/lib/kv/addon/templates/secret/paths.hbs
new file mode 100644
index 0000000000..bc415b0c60
--- /dev/null
+++ b/ui/lib/kv/addon/templates/secret/paths.hbs
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js
index e9230e7c23..a9bf7eedec 100644
--- a/ui/tests/helpers/kv/kv-selectors.js
+++ b/ui/tests/helpers/kv/kv-selectors.js
@@ -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
diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js
new file mode 100644
index 0000000000..342e7e90e6
--- /dev/null
+++ b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js
@@ -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`
+
+ `,
+ { 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`
+
+ `,
+ { 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`
+
+ `,
+ { 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`
+
+ `,
+ { 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);
+ });
+});