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

KV Suggestion Input #24447

Merged
Merged
6 changes: 4 additions & 2 deletions ui/lib/core/addon/components/filter-input.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
~}}

<div class="control has-icons-left">
<div class="control {{unless @hideIcon 'has-icons-left'}}" data-test-filter-input-container>
<input class="filter input" ...attributes {{on "input" this.onInput}} {{did-insert this.focus}} data-test-filter-input />
<Icon @name="search" class="search-icon has-text-grey-light" />
{{#unless @hideIcon}}
<Icon @name="search" class="search-icon has-text-grey-light" data-test-filter-input-icon />
{{/unless}}
</div>
3 changes: 2 additions & 1 deletion ui/lib/core/addon/components/filter-input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
Expand All @@ -12,6 +12,7 @@ import type { HTMLElementEvent } from 'vault/forms';
interface Args {
wait?: number; // defaults to 500
autofocus?: boolean; // initially focus the input on did-insert
hideIcon?: boolean; // hide the search icon in the input
onInput(value: string): void; // invoked with input value after debounce timer expires
}

Expand Down
31 changes: 31 additions & 0 deletions ui/lib/core/addon/components/kv-suggestion-input.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div ...attributes {{did-update this.updateSuggestions @mountPath}}>
<FormFieldLabel @label={{@label}} @subText={{@subText}} />
<FilterInput
id={{this.inputId}}
placeholder="Path to secret"
value={{@value}}
disabled={{not @mountPath}}
@hideIcon={{true}}
@onInput={{this.onInput}}
{{on "click" this.onInputClick}}
data-test-kv-suggestion-input
/>
<PowerSelect
@eventType="click"
@options={{this.secrets}}
@onChange={{this.onSuggestionSelect}}
@renderInPlace={{true}}
@disabled={{not @mountPath}}
@registerAPI={{fn (mut this.powerSelectAPI)}}
@triggerClass="is-hidden"
@noMatchesMessage="No suggestions for this path"
data-test-kv-suggestion-select
as |secret|
>
{{secret.path}}
</PowerSelect>
</div>
149 changes: 149 additions & 0 deletions ui/lib/core/addon/components/kv-suggestion-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { run } from '@ember/runloop';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';

import type StoreService from 'vault/services/store';
import type KvSecretMetadataModel from 'vault/models/kv/metadata';

/**
* @module KvSuggestionInput
* Input component that fetches secrets at a provided mount path and displays them as suggestions in a dropdown
* As the user types the result set will be filtered providing suggestions for the user to select
* After the input debounce wait time (500ms), if the value ends in a slash, secrets will be fetched at that path
* The new result set will then be displayed in the dropdown as suggestions for the newly inputted path
* Selecting a suggestion will append it to the input value
* This allows the user to build a full path to a secret for the provided mount
* This is useful for helping the user find deeply nested secrets given the path based policy system
* If the user does not have list permission they are still able to enter a path to a secret but will not see suggestions
*
* @example
* <KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}} // input disabled when mount path is not provided
@onChange={{fn (mut this.secretPath)}}
/>
*/

interface Args {
label: string;
subText?: string;
mountPath: string;
value: string;
onChange: CallableFunction;
}

interface PowerSelectAPI {
actions: {
open(): void;
close(): void;
};
}

export default class KvSuggestionInputComponent extends Component<Args> {
@service declare readonly store: StoreService;

@tracked secrets: KvSecretMetadataModel[] = [];
powerSelectAPI: PowerSelectAPI | undefined;
_cachedSecrets: KvSecretMetadataModel[] = []; // cache the response for filtering purposes

constructor(owner: unknown, args: Args) {
super(owner, args);
if (this.args.mountPath) {
this.updateSuggestions();
}
}

get inputId() {
// add unique segment to id in case multiple instances of component are used on the same page
return `suggestion-input-${guidFor(this)}`;
}

async fetchSecrets(isDirectory: boolean) {
const { mountPath } = this.args;
try {
const backend = keyIsFolder(mountPath) ? mountPath.slice(0, -1) : mountPath;
Copy link
Contributor

@hellobontempo hellobontempo Dec 11, 2023

Choose a reason for hiding this comment

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

I'm curious why this is necessary? secret engines should never have a slash in the name - could we just pass backend: this.args.mountPath directly below?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the sync page component we are doing a query for secret-engine models and then grabbing the path value which ends in a slash. I'm not seeing a name attribute or getter or anything on that model to use instead. In any case I think this makes it more flexible to allow the mount to be passed with or without a slash.

const parentDirectory = parentKeyForKey(this.args.value);
const pathToSecret = isDirectory ? this.args.value : parentDirectory;
const kvModels = (await this.store.query('kv/metadata', {
backend,
pathToSecret,
})) as unknown;
// this will be used to filter the existing result set when the search term changes within the same path
this._cachedSecrets = kvModels as KvSecretMetadataModel[];
return this._cachedSecrets;
} catch (error) {
console.log(error); // eslint-disable-line
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to keep this console.log? It seems like we should just silently error 🤔

Copy link
Contributor Author

@zofskeez zofskeez Dec 11, 2023

Choose a reason for hiding this comment

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

I was running into troubles trying to debug an issue which was actually erroring here (something other than 404 or 403) so I think it is helpful having it logged to the console, even for a user if they contacting support and are trying to gather information.

return [];
}
}

filterSecrets(kvModels: KvSecretMetadataModel[] | undefined = [], isDirectory: boolean) {
const { value } = this.args;
const secretName = keyWithoutParentKey(value) || '';
return kvModels.filter((model) => {
if (!value || isDirectory) {
return true;
}
if (value === model.fullSecretPath) {
// don't show suggestion if it's currently selected
return false;
}
return model.path.toLowerCase().includes(secretName.toLowerCase());
});
}

@action
async updateSuggestions() {
const isFirstUpdate = !this._cachedSecrets.length;
const isDirectory = keyIsFolder(this.args.value);
if (!this.args.mountPath) {
this.secrets = [];
} else if (this.args.value && !isDirectory && this.secrets) {
// if we don't need to fetch from a new path, filter the previous result set with the updated search term
this.secrets = this.filterSecrets(this._cachedSecrets, isDirectory);
} else {
const kvModels = await this.fetchSecrets(isDirectory);
this.secrets = this.filterSecrets(kvModels, isDirectory);
}
// don't do anything on first update -- allow dropdown to open on input click
if (!isFirstUpdate) {
const action = this.secrets.length ? 'open' : 'close';
this.powerSelectAPI?.actions[action]();
}
}

@action
onInput(value: string) {
this.args.onChange(value);
this.updateSuggestions();
}

@action
onInputClick() {
if (this.secrets.length) {
this.powerSelectAPI?.actions.open();
}
}

@action
onSuggestionSelect(secret: KvSecretMetadataModel) {
// user may partially type a value to filter result set and then select a suggestion
// in this case the partially typed value must be replaced with suggestion value
// the fullSecretPath contains the previous selections or typed path segments
this.args.onChange(secret.fullSecretPath);
this.updateSuggestions();
// refocus the input after selection
run(() => document.getElementById(this.inputId)?.focus());
}
}
6 changes: 6 additions & 0 deletions ui/lib/core/app/components/kv-suggestion-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

export { default } from 'core/components/kv-suggestion-input';
Original file line number Diff line number Diff line change
Expand Up @@ -36,38 +36,25 @@
<FilterInput
placeholder="KV engine mount path"
value={{this.mountPath}}
@onInput={{this.onMountInput}}
@onInput={{fn (mut this.mountPath)}}
data-test-sync-mount-input
/>
{{/if}}

<FormFieldLabel
<KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}}
@onChange={{fn (mut this.secretPath)}}
/>
<FilterInput
placeholder="Path to secret"
value={{this.secretPath}}
disabled={{not this.mountPath}}
@onInput={{this.onSecretInput}}
{{on "click" this.onSecretInputClick}}
data-test-sync-secret-input
/>
<PowerSelect
@eventType="click"
@options={{this.secrets}}
@onChange={{this.onSuggestionSelect}}
@renderInPlace={{true}}
@closeOnSelect={{false}}
@disabled={{not this.mountPath}}
@registerAPI={{fn (mut this.powerSelectAPI)}}
@triggerClass="is-hidden"
@noMatchesMessage="No suggestions for this path"
data-test-sync-secret-select
as |secret|
>
{{secret.path}}
</PowerSelect>
{{#if this.isSecretDirectory}}
<AlertInline
@type="warning"
@message="Syncing secret directories is not available at this time, please type a path to a single secret"
class="has-top-margin-s"
/>
{{/if}}

<div class="field is-grouped-split box is-fullwidth is-bottomless has-top-margin-xxxl">
<Hds::ButtonSet>
Expand All @@ -76,10 +63,10 @@
@color="primary"
@icon={{if this.setAssociation.isRunning "loading"}}
type="submit"
disabled={{or (not (and this.mountPath this.secretPath)) this.setAssociation.isRunning}}
disabled={{or (not (and this.mountPath this.secretPath)) this.isSecretDirectory this.setAssociation.isRunning}}
data-test-sync-submit
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" this.cancel}} data-test-sync-cancel />
<Hds::Button @text="Back" @color="secondary" {{on "click" this.back}} data-test-sync-cancel />
</Hds::ButtonSet>
</div>
</div>
Expand Down
Loading