diff --git a/changelog/20481.txt b/changelog/20481.txt new file mode 100644 index 000000000..c6f271163 --- /dev/null +++ b/changelog/20481.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Add filtering by engine type and engine name to the Secret Engine list view. +``` diff --git a/ui/app/controllers/vault/cluster/secrets/backends.js b/ui/app/controllers/vault/cluster/secrets/backends.js index fd1ef17c7..13420ef8d 100644 --- a/ui/app/controllers/vault/cluster/secrets/backends.js +++ b/ui/app/controllers/vault/cluster/secrets/backends.js @@ -2,36 +2,76 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ - -import { filterBy } from '@ember/object/computed'; -import { computed } from '@ember/object'; +/* eslint ember/no-computed-properties-in-native-classes: 'warn' */ import Controller from '@ember/controller'; -import { task } from 'ember-concurrency'; -import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { inject as service } from '@ember/service'; -const LINKED_BACKENDS = supportedSecretBackends(); +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { filterBy } from '@ember/object/computed'; +import { dropTask } from 'ember-concurrency'; -export default Controller.extend({ - flashMessages: service(), - displayableBackends: filterBy('model', 'shouldIncludeInList'), +export default class VaultClusterSecretsBackendController extends Controller { + @service flashMessages; + @filterBy('model', 'shouldIncludeInList') displayableBackends; - supportedBackends: computed('displayableBackends', 'displayableBackends.[]', function () { - return (this.displayableBackends || []) - .filter((backend) => LINKED_BACKENDS.includes(backend.get('engineType'))) - .sortBy('id'); - }), + @tracked secretEngineOptions = []; + @tracked selectedEngineType = null; + @tracked selectedEngineName = null; - unsupportedBackends: computed( - 'displayableBackends', - 'displayableBackends.[]', - 'supportedBackends', - 'supportedBackends.[]', - function () { - return (this.displayableBackends || []).slice().removeObjects(this.supportedBackends).sortBy('id'); + get sortedDisplayableBackends() { + // show supported secret engines first and then organize those by id. + const sortedBackends = this.displayableBackends.sort( + (a, b) => b.isSupportedBackend - a.isSupportedBackend || a.id - b.id + ); + + // return an options list to filter by engine type, ex: 'kv' + if (this.selectedEngineType) { + // check first if the user has also filtered by name. + if (this.selectedEngineName) { + return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); + } + // otherwise filter by engine type + return sortedBackends.filter((backend) => this.selectedEngineType === backend.engineType); } - ), - disableEngine: task(function* (engine) { + // return an options list to filter by engine name, ex: 'secret' + if (this.selectedEngineName) { + return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); + } + // no filters, return full sorted list. + return sortedBackends; + } + + get secretEngineArrayByType() { + const arrayOfAllEngineTypes = this.sortedDisplayableBackends.map((modelObject) => modelObject.engineType); + // filter out repeated engineTypes (e.g. [kv, kv] => [kv]) + const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)]; + + return arrayOfUniqueEngineTypes.map((engineType) => ({ + name: engineType, + id: engineType, + })); + } + + get secretEngineArrayByName() { + return this.sortedDisplayableBackends.map((modelObject) => ({ + name: modelObject.id, + id: modelObject.id, + })); + } + + @action + filterEngineType([type]) { + this.selectedEngineType = type; + } + + @action + filterEngineName([name]) { + this.selectedEngineName = name; + } + + @dropTask + *disableEngine(engine) { const { engineType, path } = engine; try { yield engine.destroyRecord(); @@ -41,5 +81,5 @@ export default Controller.extend({ `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${err.errors.join(' ')}.` ); } - }).drop(), -}); + } +} diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index dbe083c4e..afda9977d 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -8,6 +8,9 @@ import { computed } from '@ember/object'; // eslint-disable-line import { equal } from '@ember/object/computed'; // eslint-disable-line import { withModelValidations } from 'vault/decorators/model-validations'; import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; + +const LINKED_BACKENDS = supportedSecretBackends(); // identity will be managed separately and the inclusion // of the system backend is an implementation detail @@ -143,6 +146,27 @@ export default class SecretEngineModel extends Model { return !LIST_EXCLUDED_BACKENDS.includes(this.engineType); } + get isSupportedBackend() { + return LINKED_BACKENDS.includes(this.engineType); + } + + get backendLink() { + if (this.engineType === 'kmip') { + return 'vault.cluster.secrets.backend.kmip.scopes'; + } + if (this.engineType === 'database') { + return 'vault.cluster.secrets.backend.overview'; + } + return 'vault.cluster.secrets.backend.list-root'; + } + + get accessor() { + if (this.version === 2) { + return `v2 ${this.accessor}`; + } + return this.accessor; + } + get localDisplay() { return this.local ? 'local' : 'replicated'; } diff --git a/ui/app/templates/vault/cluster/secrets/backends.hbs b/ui/app/templates/vault/cluster/secrets/backends.hbs index e4bd54c59..429813f6a 100644 --- a/ui/app/templates/vault/cluster/secrets/backends.hbs +++ b/ui/app/templates/vault/cluster/secrets/backends.hbs @@ -7,106 +7,115 @@ + + + + Enable new engine - -{{#each this.supportedBackends as |backend|}} - {{#let - (if - (eq backend.engineType "kmip") - "vault.cluster.secrets.backend.kmip.scopes" - (if - (eq backend.engineType "database") "vault.cluster.secrets.backend.overview" "vault.cluster.secrets.backend.list-root" - ) - ) - as |backendLink| - }} - - - - - - - - - - - {{/let}} -{{/each}} - -{{#each this.unsupportedBackends as |backend|}} - - - - +{{#each this.sortedDisplayableBackends as |backend|}} + +
+
+ {{#if backend.icon}} + + + + + +
+ {{or backend.engineType backend.path}} +
+
+
+ {{/if}} + {{#if backend.path}} + {{#if backend.isSupportedBackend}} + + {{backend.path}} + + {{else}} + {{backend.path}} + {{/if}} + {{/if}} +
+ {{#if backend.accessor}} + + {{backend.accessor}} + + {{/if}} + {{#if backend.description}} + + {{backend.description}} + + {{/if}} + {{yield}} +
+ {{! meatball sandwich menu }} +
+ -
+
{{/each}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/linkable-item.js b/ui/lib/core/addon/components/linkable-item.js deleted file mode 100644 index 00ea3100c..000000000 --- a/ui/lib/core/addon/components/linkable-item.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import Component from '@glimmer/component'; -import layout from '../templates/components/linkable-item'; -import { setComponentTemplate } from '@ember/component'; - -/** - * @module LinkableItem - * LinkableItem components have two contextual components, a Content component used to show information on the left with a Menu component on the right, all aligned vertically centered. If passed a link, the block will be clickable. - * - * @example - * ```js - * - * // Use and here - * - * ``` - * - * @param {object} [link=null] - Link should have route and model - * @param {boolean} [disabled=false] - If no link then should be given a disabled attribute equal to true - */ - -/* eslint ember/no-empty-glimmer-component-classes: 'warn' */ -class LinkableItemComponent extends Component {} - -export default setComponentTemplate(layout, LinkableItemComponent); diff --git a/ui/lib/core/addon/components/linkable-item/content.js b/ui/lib/core/addon/components/linkable-item/content.js deleted file mode 100644 index 5f29692e3..000000000 --- a/ui/lib/core/addon/components/linkable-item/content.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import Component from '@glimmer/component'; -import layout from '../../templates/components/linkable-item/content'; -import { setComponentTemplate } from '@ember/component'; - -/** - * @module Content - * Content components are contextual components of LinkableItem, used to display content on the left side of a LinkableItem component. - * - * @example - * ```js - * - * - * - * ``` - * @param {string} accessor=null - formatted as HTML tag - * @param {string} description=null - will truncate if wider than parent div - * @param {string} glyphText=null - tooltip for glyph - * @param {string} glyph=null - will display as icon beside the title - * @param {string} title=null - if @link object is passed in then title will link to @link.route - */ - -/* eslint ember/no-empty-glimmer-component-classes: 'warn' */ -class ContentComponent extends Component {} - -export default setComponentTemplate(layout, ContentComponent); diff --git a/ui/lib/core/addon/components/linkable-item/menu.js b/ui/lib/core/addon/components/linkable-item/menu.js deleted file mode 100644 index acc36b3ee..000000000 --- a/ui/lib/core/addon/components/linkable-item/menu.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import Component from '@glimmer/component'; -import layout from '../../templates/components/linkable-item/menu'; -import { setComponentTemplate } from '@ember/component'; - -/** - * @module Menu - * Menu components are contextual components of LinkableItem, used to display a menu on the right side of a LinkableItem component. - * - * @example - * ```js - * - * - * Some menu here - * - * - * ``` - */ - -/* eslint ember/no-empty-glimmer-component-classes: 'warn' */ -class MenuComponent extends Component {} - -export default setComponentTemplate(layout, MenuComponent); diff --git a/ui/lib/core/addon/components/search-select.hbs b/ui/lib/core/addon/components/search-select.hbs index 94a9a7d27..2a6ebfd74 100644 --- a/ui/lib/core/addon/components/search-select.hbs +++ b/ui/lib/core/addon/components/search-select.hbs @@ -39,6 +39,7 @@ @onChange={{this.selectOrCreate}} @placeholderComponent={{component "search-select-placeholder"}} @verticalPosition="below" + @disabled={{@disabled}} as |option| > {{#if this.shouldRenderName}} diff --git a/ui/lib/core/addon/components/search-select.js b/ui/lib/core/addon/components/search-select.js index 7380e6d57..f3c13c407 100644 --- a/ui/lib/core/addon/components/search-select.js +++ b/ui/lib/core/addon/components/search-select.js @@ -53,6 +53,7 @@ import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-ut * @param {string} [placeholder] - text you wish to replace the default "search" with * @param {boolean} [displayInherit=false] - if you need the search select component to display inherit instead of box. * @param {function} [renderInfoTooltip] - receives each inputValue string and list of dropdownOptions as args, so parent can determine when to render a tooltip beside a selectedOption and the tooltip text. see 'oidc/provider-form.js' + * @param {boolean} [disabled] - if true sets the disabled property on the ember-power-select component and makes it unusable. * // * advanced customization * @param {Array} options - array of objects passed directly to the power-select component. If doing this, `models` should not also be passed as that will overwrite the diff --git a/ui/lib/core/addon/templates/components/linkable-item.hbs b/ui/lib/core/addon/templates/components/linkable-item.hbs deleted file mode 100644 index d2de17e04..000000000 --- a/ui/lib/core/addon/templates/components/linkable-item.hbs +++ /dev/null @@ -1,17 +0,0 @@ -
- {{#if @disabled}} -
- {{yield (hash content=(component "linkable-item/content"))}} - {{yield (hash menu=(component "linkable-item/menu"))}} -
- {{else}} - - {{yield (hash content=(component "linkable-item/content"))}} - {{yield (hash menu=(component "linkable-item/menu"))}} - - {{/if}} -
\ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/linkable-item/content.hbs b/ui/lib/core/addon/templates/components/linkable-item/content.hbs deleted file mode 100644 index d8f77fa01..000000000 --- a/ui/lib/core/addon/templates/components/linkable-item/content.hbs +++ /dev/null @@ -1,44 +0,0 @@ -
-
- {{#if @glyph}} - - - - - -
- {{or @glyphText @title}} -
-
-
- {{/if}} - - {{#if @title}} - {{#if @link}} - - {{@title}} - - {{else}} - {{@title}} - {{/if}} - {{/if}} -
- {{#if @accessor}} - - {{@accessor}} - - {{/if}} - - {{#if @description}} - - {{@description}} - - {{/if}} - - {{yield}} -
\ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/linkable-item/menu.hbs b/ui/lib/core/addon/templates/components/linkable-item/menu.hbs deleted file mode 100644 index 2361fb7da..000000000 --- a/ui/lib/core/addon/templates/components/linkable-item/menu.hbs +++ /dev/null @@ -1,3 +0,0 @@ -
- {{yield}} -
\ No newline at end of file diff --git a/ui/lib/core/app/components/linkable-item.js b/ui/lib/core/app/components/linkable-item.js deleted file mode 100644 index 628caf891..000000000 --- a/ui/lib/core/app/components/linkable-item.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -export { default } from 'core/components/linkable-item'; diff --git a/ui/tests/acceptance/secrets/backend/engines-test.js b/ui/tests/acceptance/secrets/backend/engines-test.js index b6b7ef8f0..617799259 100644 --- a/ui/tests/acceptance/secrets/backend/engines-test.js +++ b/ui/tests/acceptance/secrets/backend/engines-test.js @@ -4,15 +4,21 @@ */ import { currentRouteName, settled } from '@ember/test-helpers'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; +import { create } from 'ember-cli-page-object'; import { module, test } from 'qunit'; +import { runCommands } from 'vault/tests/helpers/pki/pki-run-commands'; import { setupApplicationTest } from 'ember-qunit'; import { v4 as uuidv4 } from 'uuid'; import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; import backendsPage from 'vault/tests/pages/secrets/backends'; import authPage from 'vault/tests/pages/auth'; +import ss from 'vault/tests/pages/components/search-select'; -module('Acceptance | engine/disable', function (hooks) { +const searchSelect = create(ss); + +module('Acceptance | secret-engine list view', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(function () { @@ -20,7 +26,7 @@ module('Acceptance | engine/disable', function (hooks) { return authPage.login(); }); - test('disable engine', async function (assert) { + test('it allows you to disable an engine', async function (assert) { // first mount an engine so we can disable it. const enginePath = `alicloud-disable-${this.uid}`; await mountSecrets.enable('alicloud', enginePath); @@ -41,11 +47,68 @@ module('Acceptance | engine/disable', function (hooks) { 'vault.cluster.secrets.backends', 'redirects to the backends page' ); - assert.strictEqual( backendsPage.rows.filterBy('path', `${enginePath}/`).length, 0, 'does not show the disabled engine' ); }); + + test('it adds disabled css styling to unsupported secret engines', async function (assert) { + assert.expect(2); + // first mount engine that is not supported + const enginePath = `nomad-${this.uid}`; + + await mountSecrets.enable('nomad', enginePath); + await settled(); + await backendsPage.visit(); + await settled(); + + const rows = document.querySelectorAll('[data-test-auth-backend-link]'); + const rowUnsupported = Array.from(rows).filter((row) => row.innerText.includes('nomad')); + const rowSupported = Array.from(rows).filter((row) => row.innerText.includes('cubbyhole')); + assert + .dom(rowUnsupported[0]) + .doesNotHaveClass( + 'linked-block', + `the linked-block class is not added to unsupported engines, which effectively disables it.` + ); + assert.dom(rowSupported[0]).hasClass('linked-block', `linked-block class is added to supported engines.`); + + // cleanup + await runCommands([`delete sys/mounts/${enginePath}`]); + }); + + test('it filters by name and engine type', async function (assert) { + assert.expect(3); + const enginePath1 = `aws-1-${this.uid}`; + const enginePath2 = `aws-2-${this.uid}`; + + await mountSecrets.enable('aws', enginePath1); + await mountSecrets.enable('aws', enginePath2); + await backendsPage.visit(); + await settled(); + // filter by type + await clickTrigger('#filter-by-engine-type'); + await searchSelect.options.objectAt(0).click(); + + const rows = document.querySelectorAll('[data-test-auth-backend-link]'); + const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws')); + + assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws'); + // filter by name + await clickTrigger('#filter-by-engine-name'); + await searchSelect.options.objectAt(1).click(); + const singleRow = document.querySelectorAll('[data-test-auth-backend-link]'); + + assert.dom(singleRow[0]).includesText('aws-2', 'shows the filtered by name engine'); + // clear filter by engine name + await searchSelect.deleteButtons.objectAt(1).click(); + const rowsAgain = document.querySelectorAll('[data-test-auth-backend-link]'); + assert.ok(rowsAgain.length > 1, 'filter has been removed'); + + // cleanup + await runCommands([`delete sys/mounts/${enginePath1}`]); + await runCommands([`delete sys/mounts/${enginePath2}`]); + }); }); diff --git a/ui/tests/acceptance/settings/mount-secret-backend-test.js b/ui/tests/acceptance/settings/mount-secret-backend-test.js index 33c9152e5..12c3ef684 100644 --- a/ui/tests/acceptance/settings/mount-secret-backend-test.js +++ b/ui/tests/acceptance/settings/mount-secret-backend-test.js @@ -126,7 +126,7 @@ module('Acceptance | settings/mount-secret-backend', function (hooks) { await page.secretList(); await settled(); assert - .dom(`[data-test-secret-backend-row=${path}]`) + .dom(`[data-test-auth-backend-link=${path}]`) .exists({ count: 1 }, 'renders only one instance of the engine'); }); diff --git a/ui/tests/integration/components/linkable-item-test.js b/ui/tests/integration/components/linkable-item-test.js deleted file mode 100644 index 1b376da1a..000000000 --- a/ui/tests/integration/components/linkable-item-test.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -module('Integration | Component | linkable-item', function (hooks) { - setupRenderingTest(hooks); - - test('it renders anything passed in', async function (assert) { - await render(hbs``); - assert.dom(this.element).hasText('', 'No content rendered'); - - await render(hbs` - - - stuff here - - - menu - - - `); - assert.dom('[data-test-linkable-item-content]').hasText('stuff here'); - assert.dom('[data-test-linkable-item-menu]').hasText('menu'); - }); - - test('it is not wrapped in a linked block if disabled is true', async function (assert) { - await render(hbs` - - - stuff here - - - `); - assert.dom('.list-item-row').exists('List item row exists'); - assert.dom('.list-item-row.linked-block').doesNotExist('Does not render linked block'); - assert.dom('[data-test-secret-path]').doesNotExist('Title is not rendered'); - assert.dom('[data-test-linkable-item-accessor]').doesNotExist('Accessor is not rendered'); - assert.dom('[data-test-linkable-item-accessor]').doesNotExist('Accessor is not rendered'); - assert.dom('[data-test-linkable-item-glyph]').doesNotExist('Glyph is not rendered'); - }); - - test('it is wrapped in a linked block if a link is passed', async function (assert) { - await render(hbs` - - - stuff here - - - `); - - assert.dom('.list-item-row.linked-block').exists('Renders linked block'); - }); - - test('it renders standard attributes on content', async function (assert) { - this.set('title', 'A Title'); - this.set('accessor', 'my accessor'); - this.set('description', 'my description'); - this.set('glyph', 'key'); - this.set('glyphText', 'Here is some extra info'); - - // Template block usage: - await render(hbs` - - - - `); - assert.dom('.list-item-row').exists('List item row exists'); - assert.dom('[data-test-secret-path]').hasText(this.title, 'Title is rendered'); - assert.dom('[data-test-linkable-item-accessor]').hasText(this.accessor, 'Accessor is rendered'); - assert.dom('[data-test-linkable-item-description]').hasText(this.description, 'Description is rendered'); - assert.dom('[data-test-linkable-item-glyph]').exists('Glyph is rendered'); - }); -}); diff --git a/ui/tests/pages/secrets/backends.js b/ui/tests/pages/secrets/backends.js index f0dc796e4..1a7d3ebbb 100644 --- a/ui/tests/pages/secrets/backends.js +++ b/ui/tests/pages/secrets/backends.js @@ -9,7 +9,7 @@ import uiPanel from 'vault/tests/pages/components/console/ui-panel'; export default create({ consoleToggle: clickable('[data-test-console-toggle]'), visit: visitable('/vault/secrets'), - rows: collection('[data-test-secret-backend-row]', { + rows: collection('[data-test-auth-backend-link]', { path: text('[data-test-secret-path]'), menu: clickable('[data-test-popup-menu-trigger]'), }),