Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: add copyable paths for CLI and API commands to kv v2 #22551

Merged
merged 16 commits into from
Aug 25, 2023
Merged
3 changes: 3 additions & 0 deletions changelog/22551.txt
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion ui/lib/core/addon/components/code-snippet.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion ui/lib/core/addon/components/info-table-row.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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'}}"
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved
data-test-label-div
{{did-insert this.calculateLabelOverflow}}
>
Expand Down
1 change: 1 addition & 0 deletions ui/lib/kv/addon/components/page/secret/details.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
63 changes: 63 additions & 0 deletions ui/lib/kv/addon/components/page/secret/paths.hbs
Original file line number Diff line number Diff line change
@@ -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 }}
kiannaquach marked this conversation as resolved.
Show resolved Hide resolved
<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">
Copy link
Contributor

@kiannaquach kiannaquach Aug 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Future improvement + for different project]: Refactor the DocLink to use the HDS Inline Link. Stylistically it doesn't make a big difference, but might be good to have.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the note! I'm trying to decide the best way to adopt the HDS links 🤔 They're great out of the box in place of <LinkTo>.

But for our other link components I'm going back and forth a lot. We could get rid of our components all together and just use HDS components directly...however I like that <DocLink> prefills the path. It was really useful having the host default for when the website changed hosts, we only had to update links in a few places instead of everywhere that had the old web address.

One thought is to have an @inline arg that renders the HDS inline component. Another option is to that if the component has an @icon arg it renders a standalone link, otherwise it renders inline.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yeah, links are a tough one to make decisions about! I like the idea of using HDS components out of the box since it eliminates confusion on what component to use. We don't have a component library with documentation so it's hard to figure out what components to use whereas hds is more explicit when comes to that. However, I also love that the DocLink makes it convenient so we don't have to prefix doc links.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - I think this is a great point that figuring out a component library could be useful to consider after HDS adoption is complete if we still have a bunch of components floating around. We previously used storybook but it became too difficult to maintain

learn more.
</DocLink>
</p>
<CodeSnippet data-test-commands="api" @clipboardCode={{this.commands.apiCopy}} @codeBlock={{this.commands.apiDisplay}} />
</div>
72 changes: 72 additions & 0 deletions ui/lib/kv/addon/components/page/secret/paths.js
Original file line number Diff line number Diff line change
@@ -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}`,
};
}
}
1 change: 1 addition & 0 deletions ui/lib/kv/addon/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
20 changes: 20 additions & 0 deletions ui/lib/kv/addon/routes/secret/paths.js
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions ui/lib/kv/addon/templates/secret/paths.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Page::Secret::Paths
@path={{this.model.path}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.secret.canReadMetadata}}
/>
5 changes: 5 additions & 0 deletions ui/tests/helpers/kv/kv-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading