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');
+ });
});