diff --git a/changelog/_12343.txt b/changelog/_12343.txt new file mode 100644 index 0000000000..e23fff9a04 --- /dev/null +++ b/changelog/_12343.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI Secret engines intro**: Onboarding intro which provides feature context to users. +``` diff --git a/ui/app/components/secret-engine/list.hbs b/ui/app/components/secret-engine/list.hbs index 683e5fee41..8232cd1166 100644 --- a/ui/app/components/secret-engine/list.hbs +++ b/ui/app/components/secret-engine/list.hbs @@ -12,189 +12,212 @@ <:actions> - + {{#unless this.showWizard}} + {{#if this.hasOnlyDefaultEngines}} + + {{/if}} + + {{/unless}} -{{! Filters section }} - - - - - - - - {{#each this.secretEngineArrayByType as |type|}} - {{type.name}} - {{/each}} - - - - {{#if this.engineTypeFilters.length}} +{{else}} + {{! Filters section }} + + + - {{#each this.secretEngineArrayByVersions as |backend|}} + + {{#each this.secretEngineArrayByType as |type|}} - {{backend.version}} - + value={{type.name}} + checked={{includes type.name this.engineTypeFilters}} + {{on "click" (fn this.filterByEngineType type.name)}} + data-test-checkbox={{type.name}} + > {{type.name}} {{/each}} - {{else}} - - {{/if}} - - + + + + {{#if this.engineTypeFilters.length}} + + + + {{#each this.secretEngineArrayByVersions as |backend|}} + + {{backend.version}} + + {{/each}} + {{else}} + + {{/if}} + + - - {{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}} - No filters applied. + + {{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}} + No filters applied. + {{else}} + Filters applied: + {{#each this.engineTypeFilters as |type|}} + + {{/each}} + {{#each this.engineVersionFilters as |version|}} + + {{/each}} + + {{/if}} + + {{! End Filters Section }} + + {{! Table Section }} + {{#if this.sortedDisplayableBackends}} + + <:selectedItems> + {{#if this.selectedItems}} + + + {{this.selectedItems.length}} + selected out of + {{this.sortedDisplayableBackends.length}} + + + + {{/if}} + + <:customTableItem as |itemData|> + {{#let (this.getEngineResourceData itemData.path) as |backendData|}} + + + + {{#if backendData.isSupportedBackend}} + {{backendData.path}} + {{else}} + {{backendData.path}} + {{/if}} + {{/let}} + + + <:popupMenu as |rowData|> + {{#let (this.getEngineResourceData rowData.path) as |backendData|}} + + + View configuration + {{#if (not-eq backendData.type "cubbyhole")}} + Delete + {{/if}} + + {{/let}} + + {{else}} - Filters applied: - {{#each this.engineTypeFilters as |type|}} - - {{/each}} - {{#each this.engineVersionFilters as |version|}} - - {{/each}} - + {{/if}} + {{! End Table Section }} + + {{#if this.engineToDisable}} + {{/if}} - -{{! End Filters Section }} -{{! Table Section }} -{{#if this.sortedDisplayableBackends}} - - <:selectedItems> - {{#if this.selectedItems}} - - - {{this.selectedItems.length}} - selected out of - {{this.sortedDisplayableBackends.length}} - - - - {{/if}} - - <:customTableItem as |itemData|> - {{#let (this.getEngineResourceData itemData.path) as |backendData|}} - - - - {{#if backendData.isSupportedBackend}} - {{backendData.path}} - {{else}} - {{backendData.path}} - {{/if}} - {{/let}} - - - <:popupMenu as |rowData|> - {{#let (this.getEngineResourceData rowData.path) as |backendData|}} - - - View configuration - {{#if (not-eq backendData.type "cubbyhole")}} - Delete - {{/if}} - - {{/let}} - - -{{else}} - -{{/if}} -{{! End Table Section }} - -{{#if this.engineToDisable}} - -{{/if}} - -{{#if this.enginesToDisable}} - + {{#if this.enginesToDisable}} + + {{/if}} {{/if}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/list.ts b/ui/app/components/secret-engine/list.ts index 8794f55039..54afb8516e 100644 --- a/ui/app/components/secret-engine/list.ts +++ b/ui/app/components/secret-engine/list.ts @@ -18,6 +18,8 @@ import type NamespaceService from 'vault/services/namespace'; import type RouterService from '@ember/routing/router-service'; import type SecretsEngineResource from 'vault/resources/secrets/engine'; import type VersionService from 'vault/services/version'; +import type WizardService from 'vault/services/wizard'; +import { WIZARD_ID } from '../wizard/secret-engines/secret-engines-wizard'; /** * @module SecretEngineList handles the display of the list of secret engines, including the filtering. @@ -35,11 +37,12 @@ interface Args { } export default class SecretEngineList extends Component { - @service declare readonly flashMessages: FlashMessageService; @service declare readonly api: ApiService; + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly namespace: NamespaceService; @service declare readonly router: RouterService; @service declare readonly version: VersionService; - @service declare readonly namespace: NamespaceService; + @service declare readonly wizard: WizardService; @tracked secretEngineOptions: Array | [] = []; @tracked engineToDisable: SecretsEngineResource | undefined = undefined; @@ -55,6 +58,9 @@ export default class SecretEngineList extends Component { @tracked selectedItems = Array(); + @tracked shouldRenderIntroModal = false; + wizardId = WIZARD_ID; + tableColumns = [ { key: 'path', @@ -189,6 +195,32 @@ export default class SecretEngineList extends Component { })); } + // The backend does not directly indicate which engines were mounted by default and which have been mounted by the user + // Currently the cubbyhole/, sys/, identity/ engines are mounted by default. (secret/ is mounted in dev mode as well) + // The sys/ and identity/ engines are non-displayable engines. + // While not ideal, we can check whether there are other engines than the default cubbyhole/ engine + // to determine whether we should show the intro page + get hasOnlyDefaultEngines() { + const listedEngines = this.sortedDisplayableBackends; + return !listedEngines.length || (listedEngines.length === 1 && listedEngines[0]?.path === 'cubbyhole/'); + } + + get showWizard() { + return !this.wizard.isDismissed(this.wizardId) && this.hasOnlyDefaultEngines; + } + + @action + showIntroPage() { + // Reset the wizard dismissal state to allow re-entering the wizard + this.wizard.reset(this.wizardId); + this.shouldRenderIntroModal = true; + } + + @action + refreshSecretEngineList() { + this.router.refresh('vault.cluster.secrets.backends'); + } + // Returns engine resource data for a given engine path, needed to get icon and other metadata from SecretEnginesResource getEngineResourceData = (enginePath: string) => { return this.displayableBackends.find((backend) => backend.path === enginePath); diff --git a/ui/app/components/wizard/secret-engines/intro.hbs b/ui/app/components/wizard/secret-engines/intro.hbs new file mode 100644 index 0000000000..907a80154a --- /dev/null +++ b/ui/app/components/wizard/secret-engines/intro.hbs @@ -0,0 +1,30 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + <:features> + + Store + static + application passwords (KV), generate dynamic, time-limited credentials for databases and cloud platforms. + + + One cluster can support multiple instances of the same engine type, with a + unique path + and granular access control. + + + You’ll need a + privileged account + on the target system (root DB user or AWS Admin) that Vault can use to generate dynamic credentials, and appropriate + ACL policies to control which users can interact with those secrets. + + + \ No newline at end of file diff --git a/ui/app/components/wizard/secret-engines/secret-engines-wizard.hbs b/ui/app/components/wizard/secret-engines/secret-engines-wizard.hbs new file mode 100644 index 0000000000..c4388c655c --- /dev/null +++ b/ui/app/components/wizard/secret-engines/secret-engines-wizard.hbs @@ -0,0 +1,33 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:intro> + + + <:introActions> + + + + + + + \ No newline at end of file diff --git a/ui/app/components/wizard/secret-engines/secret-engines-wizard.ts b/ui/app/components/wizard/secret-engines/secret-engines-wizard.ts new file mode 100644 index 0000000000..725cc2a3fc --- /dev/null +++ b/ui/app/components/wizard/secret-engines/secret-engines-wizard.ts @@ -0,0 +1,40 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; + +import type ApiService from 'vault/services/api'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; +import type WizardService from 'vault/services/wizard'; + +interface Args { + isIntroModal: boolean; + onRefresh: CallableFunction; +} + +export const WIZARD_ID = 'secret-engines'; + +export default class WizardSecretEnginesWizardComponent extends Component { + @service declare readonly api: ApiService; + @service declare readonly router: RouterService; + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly wizard: WizardService; + + wizardId = WIZARD_ID; + + @action + onDismiss() { + this.wizard.dismiss(this.wizardId); + this.args.onRefresh(); + } + + @action + onIntroChange(visible: boolean) { + this.wizard.setIntroVisible(this.wizardId, visible); + } +} diff --git a/ui/public/images/secret-engines-intro.png b/ui/public/images/secret-engines-intro.png new file mode 100644 index 0000000000..279fc5e95e Binary files /dev/null and b/ui/public/images/secret-engines-intro.png differ diff --git a/ui/tests/acceptance/secret-engine-list-view-test.js b/ui/tests/acceptance/secret-engine-list-view-test.js index 342e0951f5..4266c2e8d8 100644 --- a/ui/tests/acceptance/secret-engine-list-view-test.js +++ b/ui/tests/acceptance/secret-engine-list-view-test.js @@ -19,6 +19,7 @@ import { } from 'vault/tests/helpers/commands'; import { login, loginNs } from 'vault/tests/helpers/auth/auth-helpers'; import page from 'vault/tests/pages/settings/mount-secret-backend'; +import localStorage from 'vault/lib/local-storage'; module('Acceptance | secret-engine list view', function (hooks) { setupApplicationTest(hooks); @@ -32,9 +33,11 @@ module('Acceptance | secret-engine list view', function (hooks) { await click(SES.crumb(enginePath)); }; - hooks.beforeEach(function () { + hooks.beforeEach(async function () { this.uid = uuidv4(); - return login(); + await login(); + // dismiss wizard + localStorage.setItem('dismissed-wizards', ['secret-engines']); }); // the new API service camelizes response keys, so this tests is to assert that does NOT happen when we re-implement it @@ -146,6 +149,7 @@ module('Acceptance | secret-engine list view', function (hooks) { await runCmd([`write sys/namespaces/${this.namespace} -force`]); await loginNs(this.namespace); + localStorage.setItem('dismissed-wizards', ['secret-engines']); await visit(`/vault/secrets-engines?namespace=${this.namespace}`); await click(`${GENERAL.tableData('cubbyhole/', 'path')} a`); diff --git a/ui/tests/integration/components/list-test.js b/ui/tests/integration/components/list-test.js index 5b3726b66e..e16fb0a2ae 100644 --- a/ui/tests/integration/components/list-test.js +++ b/ui/tests/integration/components/list-test.js @@ -28,6 +28,7 @@ module('Integration | Component | secret-engine/list', function (hooks) { this.version = this.owner.lookup('service:version'); this.router = this.owner.lookup('service:router'); this.router.transitionTo = sinon.stub(); + this.router.refresh = sinon.stub(); this.flashMessages = this.owner.lookup('service:flash-messages'); this.flashMessages.registerTypes(['success', 'danger']); this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); @@ -44,6 +45,11 @@ module('Integration | Component | secret-engine/list', function (hooks) { ]; }); + hooks.afterEach(async function () { + // ensure clean state + localStorage.clear(); + }); + test('it allows you to disable an engine', async function (assert) { const enginePath = 'kv-test'; this.server.delete(`sys/mounts/${enginePath}`, () => { @@ -172,4 +178,36 @@ module('Integration | Component | secret-engine/list', function (hooks) { .dom(GENERAL.tableData('aws-1/', 'path')) .hasClass('text-overflow-ellipsis', 'secret engine name has text overflow class '); }); + + test('it shows the intro page when only default engines are enabled', async function (assert) { + // Only cubbyhole engine exists (default engine) + const defaultEngines = [createSecretsEngine(undefined, 'cubbyhole', 'cubbyhole')]; + this.secretEngineModels = defaultEngines; + + await render(hbs``); + + assert.dom('[data-test-intro]').exists('Intro page is shown'); + assert.dom(GENERAL.button('intro')).exists('Shows intro button'); + assert.dom(GENERAL.button('Skip')).exists('Shows skip button'); + }); + + test('it does not show the intro page when other engines exist', async function (assert) { + // Has engines beyond the default cubbyhole + await render(hbs``); + + assert.dom('[data-test-intro]').doesNotExist('Intro modal is not shown when engines exist'); + assert.dom(GENERAL.button('intro')).doesNotExist('Intro button is not shown'); + }); + + test('it can show the intro modal after dismissal', async function (assert) { + const defaultEngines = [createSecretsEngine(undefined, 'cubbyhole', 'cubbyhole')]; + this.secretEngineModels = defaultEngines; + + await render(hbs``); + await click(GENERAL.button('Skip')); + assert.dom('[data-test-intro]').doesNotExist('Intro is dismissed'); + + await click(GENERAL.button('intro')); + assert.dom('[data-test-intro]').exists('Intro can be shown again after reset'); + }); });