UI: Add secret engines intro page (#12343) (#12379)

* Add secret engines intro page

* add test coverage

* hide header actions when showing wizard

* add changelog entry

* update copy and variable naming

* fix tests

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2026-02-17 11:44:22 -05:00 committed by GitHub
parent 21d95fb9fe
commit 8f6253cc0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 374 additions and 171 deletions

3
changelog/_12343.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:feature
**UI Secret engines intro**: Onboarding intro which provides feature context to users.
```

View file

@ -12,189 +12,212 @@
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
<:actions>
<Hds::Button @text="Enable new engine" @icon="plus" @route="vault.cluster.secrets.enable" data-test-enable-engine />
{{#unless this.showWizard}}
{{#if this.hasOnlyDefaultEngines}}
<Hds::Button
@color="secondary"
@icon="bulb"
@text="New to Secret engines?"
{{on "click" this.showIntroPage}}
data-test-button="intro"
/>
{{/if}}
<Hds::Button @text="Enable new engine" @icon="plus" @route="vault.cluster.secrets.enable" data-test-enable-engine />
{{/unless}}
</:actions>
</Page::Header>
{{! Filters section }}
<Hds::SegmentedGroup class="has-top-margin-m" as |SG|>
<SG.TextInput
@width="300px"
@type="search"
placeholder="Search by path"
aria-label="Search"
data-test-input-search="secret-engine-path"
{{on "input" (fn this.setSearchText "path")}}
{{#if this.showWizard}}
<Wizard::SecretEngines::SecretEnginesWizard
@isIntroModal={{this.shouldRenderIntroModal}}
@onRefresh={{this.refreshSecretEngineList}}
/>
<SG.Dropdown @width="175px" as |D|>
<D.Header @hasDivider={{true}}>
<SG.TextInput @type="search" placeholder="Search" aria-label="Search" {{on "input" (fn this.setSearchText "type")}} />
</D.Header>
<D.ToggleButton @color="secondary" @text="Engine type" data-test-toggle-input="filter-by-engine-type" />
{{#each this.secretEngineArrayByType as |type|}}
<D.Checkbox
value={{type.name}}
checked={{includes type.name this.engineTypeFilters}}
{{on "click" (fn this.filterByEngineType type.name)}}
data-test-checkbox={{type.name}}
><Hds::Icon @name={{type.icon}} @isInline={{true}} /> {{type.name}}</D.Checkbox>
{{/each}}
</SG.Dropdown>
<SG.Dropdown @width="250px" as |D|>
<D.ToggleButton @color="secondary" @text="Version" data-test-toggle-input="filter-by-engine-version" />
{{#if this.engineTypeFilters.length}}
{{else}}
{{! Filters section }}
<Hds::SegmentedGroup class="has-top-margin-m" as |SG|>
<SG.TextInput
@width="300px"
@type="search"
placeholder="Search by path"
aria-label="Search"
data-test-input-search="secret-engine-path"
{{on "input" (fn this.setSearchText "path")}}
/>
<SG.Dropdown @width="175px" as |D|>
<D.Header @hasDivider={{true}}>
<SG.TextInput
@type="search"
placeholder="Search"
aria-label="Search"
{{on "input" (fn this.setSearchText "version")}}
{{on "input" (fn this.setSearchText "type")}}
/>
</D.Header>
{{#each this.secretEngineArrayByVersions as |backend|}}
<D.ToggleButton @color="secondary" @text="Engine type" data-test-toggle-input="filter-by-engine-type" />
{{#each this.secretEngineArrayByType as |type|}}
<D.Checkbox
value={{backend.version}}
checked={{includes backend.version this.engineVersionFilters}}
{{on "click" (fn this.filterByEngineVersion backend.version)}}
data-test-checkbox={{backend.version}}
>
{{backend.version}}
</D.Checkbox>
value={{type.name}}
checked={{includes type.name this.engineTypeFilters}}
{{on "click" (fn this.filterByEngineType type.name)}}
data-test-checkbox={{type.name}}
><Hds::Icon @name={{type.icon}} @isInline={{true}} /> {{type.name}}</D.Checkbox>
{{/each}}
{{else}}
<D.Description class="has-top-padding-s" @text="Select an engine type first to filter by versions." />
{{/if}}
</SG.Dropdown>
</Hds::SegmentedGroup>
</SG.Dropdown>
<SG.Dropdown @width="250px" as |D|>
<D.ToggleButton @color="secondary" @text="Version" data-test-toggle-input="filter-by-engine-version" />
{{#if this.engineTypeFilters.length}}
<D.Header @hasDivider={{true}}>
<SG.TextInput
@type="search"
placeholder="Search"
aria-label="Search"
{{on "input" (fn this.setSearchText "version")}}
/>
</D.Header>
{{#each this.secretEngineArrayByVersions as |backend|}}
<D.Checkbox
value={{backend.version}}
checked={{includes backend.version this.engineVersionFilters}}
{{on "click" (fn this.filterByEngineVersion backend.version)}}
data-test-checkbox={{backend.version}}
>
{{backend.version}}
</D.Checkbox>
{{/each}}
{{else}}
<D.Description class="has-top-padding-s" @text="Select an engine type first to filter by versions." />
{{/if}}
</SG.Dropdown>
</Hds::SegmentedGroup>
<Hds::Layout::Flex @gap="8" class="has-top-margin-xs has-bottom-margin-xs" @align="center">
{{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}}
<Hds::Text::Body class="has-top-padding-xs">No filters applied.</Hds::Text::Body>
<Hds::Layout::Flex @gap="8" class="has-top-margin-xs has-bottom-margin-xs" @align="center">
{{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}}
<Hds::Text::Body class="has-top-padding-xs">No filters applied.</Hds::Text::Body>
{{else}}
<Hds::Text::Body>Filters applied:</Hds::Text::Body>
{{#each this.engineTypeFilters as |type|}}
<Hds::Tag @text={{type}} @onDismiss={{fn this.filterByEngineType type}} data-test-button={{type}} />
{{/each}}
{{#each this.engineVersionFilters as |version|}}
<Hds::Tag @text={{version}} @onDismiss={{fn this.filterByEngineVersion version}} data-test-button={{version}} />
{{/each}}
<Hds::Button
@text="Clear all"
@color="tertiary"
@icon="x"
@size="small"
data-test-button="Clear all"
{{on "click" this.clearAllFilters}}
/>
{{/if}}
</Hds::Layout::Flex>
{{! End Filters Section }}
{{! Table Section }}
{{#if this.sortedDisplayableBackends}}
<ListTable
class="has-top-margin-xs"
@columns={{this.tableColumns}}
@selectionKey="path"
@data={{this.sortedDisplayableBackends}}
@onSelectionChange={{this.updateSelectedItems}}
>
<:selectedItems>
{{#if this.selectedItems}}
<Hds::Layout::Flex
@gap="8"
@direction="row"
@justify="end"
@align="center"
class="has-bottom-margin-s has-top-margin-negative-xxl"
@isInline="true"
>
<Hds::Text::Body role="status" @tag="p" @size="200" @color="foreground-primary">
{{this.selectedItems.length}}
selected out of
{{this.sortedDisplayableBackends.length}}
</Hds::Text::Body>
<Hds::Button
@text="Disable engines"
@color="critical"
@icon="trash"
{{on "click" (fn (mut this.enginesToDisable) this.selectedItems)}}
/>
</Hds::Layout::Flex>
{{/if}}
</:selectedItems>
<:customTableItem as |itemData|>
{{#let (this.getEngineResourceData itemData.path) as |backendData|}}
<Hds::TooltipButton
aria-label="Type of backend"
@text={{this.generateToolTipText backendData}}
data-test-tooltip="Backend type"
isInline={{true}}
class="is-v-centered"
>
<Hds::Icon @name={{if backendData.icon backendData.icon "lock"}} />
</Hds::TooltipButton>
{{#if backendData.isSupportedBackend}}
<Hds::Link::Inline
@route={{backendData.backendLink}}
@model={{backendData.id}}
@color="secondary"
class="has-text-weight-semibold"
>{{backendData.path}}</Hds::Link::Inline>
{{else}}
{{backendData.path}}
{{/if}}
{{/let}}
</:customTableItem>
<:popupMenu as |rowData|>
{{#let (this.getEngineResourceData rowData.path) as |backendData|}}
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="{{if backendData.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
<dd.Interactive
@route={{backendData.backendConfigurationLink}}
@model={{backendData.id}}
data-test-popup-menu="View configuration"
@icon="settings"
>View configuration</dd.Interactive>
{{#if (not-eq backendData.type "cubbyhole")}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.engineToDisable) backendData)}}
data-test-popup-menu="Delete"
@icon="trash"
>Delete</dd.Interactive>
{{/if}}
</Hds::Dropdown>
{{/let}}
</:popupMenu>
</ListTable>
{{else}}
<Hds::Text::Body>Filters applied:</Hds::Text::Body>
{{#each this.engineTypeFilters as |type|}}
<Hds::Tag @text={{type}} @onDismiss={{fn this.filterByEngineType type}} data-test-button={{type}} />
{{/each}}
{{#each this.engineVersionFilters as |version|}}
<Hds::Tag @text={{version}} @onDismiss={{fn this.filterByEngineVersion version}} data-test-button={{version}} />
{{/each}}
<Hds::Button
@text="Clear all"
@color="tertiary"
@icon="x"
@size="small"
data-test-button="Clear all"
{{on "click" this.clearAllFilters}}
<EmptyState @title="No Secrets engines found" />
{{/if}}
{{! End Table Section }}
{{#if this.engineToDisable}}
<ConfirmModal
@color="critical"
@confirmMessage="Any data in this engine will be permanently deleted."
@confirmTitle="Disable engine?"
@onClose={{fn (mut this.engineToDisable) null}}
@onConfirm={{perform this.disableEngine this.engineToDisable}}
/>
{{/if}}
</Hds::Layout::Flex>
{{! End Filters Section }}
{{! Table Section }}
{{#if this.sortedDisplayableBackends}}
<ListTable
class="has-top-margin-xs"
@columns={{this.tableColumns}}
@selectionKey="path"
@data={{this.sortedDisplayableBackends}}
@onSelectionChange={{this.updateSelectedItems}}
>
<:selectedItems>
{{#if this.selectedItems}}
<Hds::Layout::Flex
@gap="8"
@direction="row"
@justify="end"
@align="center"
class="has-bottom-margin-s has-top-margin-negative-xxl"
@isInline="true"
>
<Hds::Text::Body role="status" @tag="p" @size="200" @color="foreground-primary">
{{this.selectedItems.length}}
selected out of
{{this.sortedDisplayableBackends.length}}
</Hds::Text::Body>
<Hds::Button
@text="Disable engines"
@color="critical"
@icon="trash"
{{on "click" (fn (mut this.enginesToDisable) this.selectedItems)}}
/>
</Hds::Layout::Flex>
{{/if}}
</:selectedItems>
<:customTableItem as |itemData|>
{{#let (this.getEngineResourceData itemData.path) as |backendData|}}
<Hds::TooltipButton
aria-label="Type of backend"
@text={{this.generateToolTipText backendData}}
data-test-tooltip="Backend type"
isInline={{true}}
class="is-v-centered"
>
<Hds::Icon @name={{if backendData.icon backendData.icon "lock"}} />
</Hds::TooltipButton>
{{#if backendData.isSupportedBackend}}
<Hds::Link::Inline
@route={{backendData.backendLink}}
@model={{backendData.id}}
@color="secondary"
class="has-text-weight-semibold"
>{{backendData.path}}</Hds::Link::Inline>
{{else}}
{{backendData.path}}
{{/if}}
{{/let}}
</:customTableItem>
<:popupMenu as |rowData|>
{{#let (this.getEngineResourceData rowData.path) as |backendData|}}
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="{{if backendData.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
<dd.Interactive
@route={{backendData.backendConfigurationLink}}
@model={{backendData.id}}
data-test-popup-menu="View configuration"
@icon="settings"
>View configuration</dd.Interactive>
{{#if (not-eq backendData.type "cubbyhole")}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.engineToDisable) backendData)}}
data-test-popup-menu="Delete"
@icon="trash"
>Delete</dd.Interactive>
{{/if}}
</Hds::Dropdown>
{{/let}}
</:popupMenu>
</ListTable>
{{else}}
<EmptyState @title="No Secrets engines found" />
{{/if}}
{{! End Table Section }}
{{#if this.engineToDisable}}
<ConfirmModal
@color="critical"
@confirmMessage="Any data in this engine will be permanently deleted."
@confirmTitle="Disable engine?"
@onClose={{fn (mut this.engineToDisable) null}}
@onConfirm={{perform this.disableEngine this.engineToDisable}}
/>
{{/if}}
{{#if this.enginesToDisable}}
<ConfirmModal
@color="critical"
@confirmMessage="Any data in these engines will be permanently deleted."
@confirmTitle="Disable engines?"
@onClose={{fn (mut this.enginesToDisable) null}}
@onConfirm={{perform this.disableMultipleEngines this.enginesToDisable}}
/>
{{#if this.enginesToDisable}}
<ConfirmModal
@color="critical"
@confirmMessage="Any data in these engines will be permanently deleted."
@confirmTitle="Disable engines?"
@onClose={{fn (mut this.enginesToDisable) null}}
@onConfirm={{perform this.disableMultipleEngines this.enginesToDisable}}
/>
{{/if}}
{{/if}}

View file

@ -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<Args> {
@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<string> | [] = [];
@tracked engineToDisable: SecretsEngineResource | undefined = undefined;
@ -55,6 +58,9 @@ export default class SecretEngineList extends Component<Args> {
@tracked selectedItems = Array<string>();
@tracked shouldRenderIntroModal = false;
wizardId = WIZARD_ID;
tableColumns = [
{
key: 'path',
@ -189,6 +195,32 @@ export default class SecretEngineList extends Component<Args> {
}));
}
// 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);

View file

@ -0,0 +1,30 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Wizard::IntroContent
@setupTime="15 min"
@description="Secrets engines in Vault securely manage, store, and dynamically generate secrets, certificates, and encryption keys."
@imageAlt="Flow of secret engine access control by policy"
@imageCaption="Access to secrets within an engine is governed by policies. Users must be granted explicit access to the engine's unique path."
@imageSrc={{img-path "~/secret-engines-intro.png"}}
>
<:features>
<Wizard::IntroContent::Feature @icon="service">
Store
<strong>static</strong>
application passwords (KV), generate dynamic, time-limited credentials for databases and cloud platforms.
</Wizard::IntroContent::Feature>
<Wizard::IntroContent::Feature @icon="replication-direct">
One cluster can support multiple instances of the same engine type, with a
<strong>unique path</strong>
and granular access control.
</Wizard::IntroContent::Feature>
<Wizard::IntroContent::Feature @icon="lock">
Youll need a
<strong>privileged account</strong>
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.
</Wizard::IntroContent::Feature>
</:features>
</Wizard::IntroContent>

View file

@ -0,0 +1,33 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Wizard @wizardId={{this.wizardId}} @isModal={{@isIntroModal}} @title="Secrets engines" @onDismiss={{this.onDismiss}}>
<:intro>
<Wizard::SecretEngines::Intro />
</:intro>
<:introActions>
<Hds::ButtonSet>
<Hds::Button
@icon="plus"
@text="Enable a Secret engine"
{{on "click" (fn this.onIntroChange false)}}
data-test-button="intro"
/>
<Hds::Button
@color="secondary"
@text={{if @isIntroModal "Close" "Skip"}}
{{on "click" this.onDismiss}}
data-test-button="Skip"
/>
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="View documentation"
@href={{doc-link "/vault/tutorials/get-started/understand-static-dynamic-secrets"}}
class="has-left-margin-m"
/>
</Hds::ButtonSet>
</:introActions>
</Wizard>

View file

@ -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<Args> {
@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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -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`);

View file

@ -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`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
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`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
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`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
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');
});
});