From b593ca128e43dd447d04cb6991eaa6d50069a4a8 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Wed, 25 Feb 2026 11:37:43 -0700 Subject: [PATCH] Backport [VAULT-40813] ManageDropdown component into ce/main (#12508) * [VAULT-40813] ManageDropdown component (#12295) * [VAULT-40813] ManageDropdown component * address pr comments, add routing test coverage * fix test: generate policy is an enterprise-only feature --------- Co-authored-by: Shannon Roberts (Beagin) Co-authored-by: Shannon Roberts --- ui/app/components/manage-dropdown.ts | 6 + ui/app/components/mount-backend-form.ts | 14 +- ui/app/components/mount-backend/type-form.js | 10 +- ui/app/components/secret-engine/list.hbs | 35 +- ui/app/components/secret-engine/list.ts | 36 +- ui/app/components/secret-engines/catalog.ts | 20 +- .../vault/cluster/secrets/backend/list.js | 25 +- ui/app/forms/secrets/engine.ts | 2 +- ui/app/helpers/engines-display-data.ts | 58 +- ui/app/models/mfa-login-enforcement.js | 6 +- ui/app/models/secret-engine.js | 13 +- ui/app/resources/secrets/engine.ts | 4 +- .../vault/cluster/secrets/backend/list.js | 14 +- .../vault/cluster/secrets/backend/list.hbs | 38 +- ui/app/utils/all-engines-metadata.ts | 353 +----------- .../core/addon/components/manage-dropdown.hbs | 46 ++ .../core/addon/components/manage-dropdown.ts | 117 ++++ .../addon/helpers/engines-display-data.ts | 62 +++ .../core/addon/utils/all-engines-metadata.ts | 353 ++++++++++++ ui/lib/core/app/components/manage-dropdown.js | 6 + .../core/app/helpers/engines-display-data.js | 6 + ui/lib/kmip/addon/components/page/scopes.hbs | 26 +- ui/lib/kmip/addon/components/page/scopes.ts | 31 +- .../addon/components/kubernetes-header.hbs | 16 +- .../addon/components/kubernetes-header.ts | 57 -- ui/lib/kv/addon/components/page/list.hbs | 27 +- ui/lib/kv/addon/components/page/list.js | 26 +- ui/lib/ldap/addon/components/ldap-header.hbs | 26 +- ui/lib/ldap/addon/components/ldap-header.ts | 57 -- .../pki/addon/components/pki-page-header.hbs | 26 +- .../pki/addon/components/pki-page-header.ts | 32 +- .../secrets/manage-dropdown-routing-test.js | 518 ++++++++++++++++++ ui/tests/acceptance/secrets/mounts-test.js | 32 +- .../secrets/secrets-nav-test-helper.js | 4 +- ui/tests/acceptance/settings-test.js | 14 +- .../components/kmip/page/scopes-test.js | 14 +- .../components/manage-dropdown-test.js | 161 ++++++ .../unit/components/manage-dropdown-test.js | 88 +++ .../unit/helpers/engines-display-data-test.js | 2 +- 39 files changed, 1490 insertions(+), 891 deletions(-) create mode 100644 ui/app/components/manage-dropdown.ts create mode 100644 ui/lib/core/addon/components/manage-dropdown.hbs create mode 100644 ui/lib/core/addon/components/manage-dropdown.ts create mode 100644 ui/lib/core/addon/helpers/engines-display-data.ts create mode 100644 ui/lib/core/addon/utils/all-engines-metadata.ts create mode 100644 ui/lib/core/app/components/manage-dropdown.js create mode 100644 ui/lib/core/app/helpers/engines-display-data.js delete mode 100644 ui/lib/kubernetes/addon/components/kubernetes-header.ts delete mode 100644 ui/lib/ldap/addon/components/ldap-header.ts create mode 100644 ui/tests/acceptance/secrets/manage-dropdown-routing-test.js create mode 100644 ui/tests/integration/components/manage-dropdown-test.js create mode 100644 ui/tests/unit/components/manage-dropdown-test.js diff --git a/ui/app/components/manage-dropdown.ts b/ui/app/components/manage-dropdown.ts new file mode 100644 index 0000000000..e524fcb744 --- /dev/null +++ b/ui/app/components/manage-dropdown.ts @@ -0,0 +1,6 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/manage-dropdown'; diff --git a/ui/app/components/mount-backend-form.ts b/ui/app/components/mount-backend-form.ts index c75e918baf..91f72c9f28 100644 --- a/ui/app/components/mount-backend-form.ts +++ b/ui/app/components/mount-backend-form.ts @@ -3,21 +3,21 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import { action, set } from '@ember/object'; +import { service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { service } from '@ember/service'; -import { action, set } from '@ember/object'; +import { filterEnginesByMountCategory } from 'core/utils/all-engines-metadata'; import { task } from 'ember-concurrency'; -import { waitFor } from '@ember/test-waiters'; -import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; import { MOUNT_CATEGORIES } from 'vault/utils/plugin-catalog-helpers'; -import type FlashMessageService from 'vault/services/flash-messages'; +import type { ApiError } from '@ember-data/adapter/error'; import type Store from '@ember-data/store'; import type AuthMethodForm from 'vault/forms/auth/method'; -import type CapabilitiesService from 'vault/services/capabilities'; import type ApiService from 'vault/services/api'; -import type { ApiError } from '@ember-data/adapter/error'; +import type CapabilitiesService from 'vault/services/capabilities'; +import type FlashMessageService from 'vault/services/flash-messages'; import type { ValidationMap } from 'vault/vault/app-types'; /** diff --git a/ui/app/components/mount-backend/type-form.js b/ui/app/components/mount-backend/type-form.js index d1859a3e5a..fcc4a3bb32 100644 --- a/ui/app/components/mount-backend/type-form.js +++ b/ui/app/components/mount-backend/type-form.js @@ -3,18 +3,18 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@glimmer/component'; -import { service } from '@ember/service'; import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import { filterEnginesByMountCategory } from 'core/utils/all-engines-metadata'; import keys from 'core/utils/keys'; -import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; import { - enhanceEnginesWithCatalogData, categorizeEnginesByStatus, + enhanceEnginesWithCatalogData, MOUNT_CATEGORIES, - PLUGIN_TYPES, PLUGIN_CATEGORIES, + PLUGIN_TYPES, } from 'vault/utils/plugin-catalog-helpers'; /** diff --git a/ui/app/components/secret-engine/list.hbs b/ui/app/components/secret-engine/list.hbs index 8232cd1166..663b66c494 100644 --- a/ui/app/components/secret-engine/list.hbs +++ b/ui/app/components/secret-engine/list.hbs @@ -140,7 +140,7 @@ @text="Disable engines" @color="critical" @icon="trash" - {{on "click" (fn (mut this.enginesToDisable) this.selectedItems)}} + {{on "click" (fn this.setEnginesToDisable this.selectedItems)}} /> {{/if}} @@ -171,28 +171,7 @@ <:popupMenu as |rowData|> {{#let (this.getEngineResourceData rowData.path) as |backendData|}} - - - View configuration - {{#if (not-eq backendData.type "cubbyhole")}} - Delete - {{/if}} - + {{/let}} @@ -201,16 +180,6 @@ {{/if}} {{! End Table Section }} - {{#if this.engineToDisable}} - - {{/if}} - {{#if this.enginesToDisable}} { @service declare readonly wizard: WizardService; @tracked secretEngineOptions: Array | [] = []; - @tracked engineToDisable: SecretsEngineResource | undefined = undefined; @tracked enginesToDisable: Array | null = null; @tracked engineTypeFilters: Array = []; @@ -289,6 +288,16 @@ export default class SecretEngineList extends Component { this.selectedItems = tableData.selectedRowsKeys; } + @action + setEnginesToDisable(engines: Array) { + this.enginesToDisable = engines; + } + + @action + clearEnginesToDisable() { + this.enginesToDisable = null; + } + async disableSingleEngine(engine: SecretsEngineResource) { const { engineType, id, path } = engine; try { @@ -302,14 +311,13 @@ export default class SecretEngineList extends Component { } } - @dropTask - *disableMultipleEngines(enginePathsToDisable: Array) { + disableMultipleEngines = dropTask(async (enginePathsToDisable: Array) => { const enginesToDisable = this.displayableBackends.filter((engine: SecretsEngineResource) => enginePathsToDisable.includes(engine.path) ); try { for (const engine of enginesToDisable) { - yield this.disableSingleEngine(engine); + await this.disableSingleEngine(engine); } // Navigate once all operations are complete @@ -317,15 +325,5 @@ export default class SecretEngineList extends Component { } finally { this.enginesToDisable = null; } - } - - @dropTask - *disableEngine(engine: SecretsEngineResource) { - try { - yield this.disableSingleEngine(engine); - this.router.transitionTo('vault.cluster.secrets.backends'); - } finally { - this.engineToDisable = undefined; - } - } + }); } diff --git a/ui/app/components/secret-engines/catalog.ts b/ui/app/components/secret-engines/catalog.ts index 550cf626f8..e6c06c5201 100644 --- a/ui/app/components/secret-engines/catalog.ts +++ b/ui/app/components/secret-engines/catalog.ts @@ -3,19 +3,19 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@glimmer/component'; -import { service } from '@ember/service'; import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; -import { - enhanceEnginesWithCatalogData, - categorizeEnginesByStatus, - MOUNT_CATEGORIES, - PLUGIN_TYPES, - PLUGIN_CATEGORIES, -} from 'vault/utils/plugin-catalog-helpers'; +import { filterEnginesByMountCategory } from 'core/utils/all-engines-metadata'; import type { PluginCatalogData } from 'vault/services/plugin-catalog'; +import { + categorizeEnginesByStatus, + enhanceEnginesWithCatalogData, + MOUNT_CATEGORIES, + PLUGIN_CATEGORIES, + PLUGIN_TYPES, +} from 'vault/utils/plugin-catalog-helpers'; import type VersionService from 'vault/services/version'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js index ab95c0c4aa..964ebfc285 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/list.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -3,14 +3,13 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { or } from '@ember/object/computed'; -import { computed } from '@ember/object'; -import { service } from '@ember/service'; import Controller from '@ember/controller'; -import BackendCrumbMixin from 'vault/mixins/backend-crumb'; +import { computed } from '@ember/object'; +import { or } from '@ember/object/computed'; +import { service } from '@ember/service'; import ListController from 'core/mixins/list-controller'; import { keyIsFolder } from 'core/utils/key-utils'; -import { task } from 'ember-concurrency'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; export default Controller.extend(ListController, BackendCrumbMixin, { flashMessages: service(), @@ -68,20 +67,4 @@ export default Controller.extend(ListController, BackendCrumbMixin, { }); }, }, - - disableEngine: task(function* (engine) { - const { engineType, id, path } = engine; - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engines at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = null; - } - }).drop(), }); diff --git a/ui/app/forms/secrets/engine.ts b/ui/app/forms/secrets/engine.ts index 7e39262492..a1dcb4f48d 100644 --- a/ui/app/forms/secrets/engine.ts +++ b/ui/app/forms/secrets/engine.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import { ALL_ENGINES } from 'core/utils/all-engines-metadata'; import MountForm from 'vault/forms/mount'; -import { ALL_ENGINES } from 'vault/utils/all-engines-metadata'; import { isKnownExternalPlugin } from 'vault/utils/external-plugin-helpers'; import FormField from 'vault/utils/forms/field'; import FormFieldGroup from 'vault/utils/forms/field-group'; diff --git a/ui/app/helpers/engines-display-data.ts b/ui/app/helpers/engines-display-data.ts index 4924e9a7e7..a71fe61517 100644 --- a/ui/app/helpers/engines-display-data.ts +++ b/ui/app/helpers/engines-display-data.ts @@ -3,60 +3,4 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { ALL_ENGINES, type EngineDisplayData } from 'vault/utils/all-engines-metadata'; -import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; - -/** - * Default metadata for unknown engine plugins - */ -export const unknownEngineMetadata = (methodType?: string): EngineDisplayData => ({ - type: methodType || 'unknown', - displayName: methodType || 'Unknown plugin', - glyph: 'lock', - mountCategory: ['secret', 'auth'], -}); - -/** - * Helper function to retrieve engine metadata for a given `methodType`. - * It searches the `ALL_ENGINES` array for an engine with a matching type and returns its metadata object. - * The `ALL_ENGINES` array includes secret and auth engines, including those supported only in enterprise. - * These details (such as mount type and enterprise licensing) are included in the returned engine object. - * - * For external plugins that have a builtin mapping (e.g., "vault-plugin-secrets-keymgmt" -> "keymgmt"), - * this function returns the metadata for the corresponding builtin engine, preserving the original - * external plugin name in the type field. - * - * Example usage: - * const engineMetadata = engineDisplayData('kmip'); - * if (engineMetadata?.requiresEnterprise) { - * console.log(`This mount: ${engineMetadata.engineType} requires an enterprise license`); - * } - * - * @param {string} methodType - The engine type (sometimes called backend) to look up (e.g., "aws", "azure", "vault-plugin-secrets-keymgmt"). - * @returns {Object} - The engine metadata, which includes information about its mount type (e.g., secret or auth) - * and whether it requires an enterprise license. For unknown engines, returns a default unknown plugin object. - */ -export default function engineDisplayData(methodType: string): EngineDisplayData { - // First try to find an exact match - const builtinEngine = ALL_ENGINES?.find((t) => t.type === methodType); - if (builtinEngine) { - return builtinEngine; - } - - // If no direct match, check if this is a known external plugin and use its builtin mapping - const effectiveType = getEffectiveEngineType(methodType); - if (effectiveType !== methodType) { - // This is a known external plugin with a builtin mapping - const mappedEngine = ALL_ENGINES?.find((t) => t.type === effectiveType); - if (mappedEngine) { - // Return the mapped engine metadata but preserve the original external plugin type - return { - ...mappedEngine, - type: methodType, // Keep the original external plugin name for identification - }; - } - } - - // Return default unknown plugin metadata - return unknownEngineMetadata(methodType); -} +export { default } from 'core/helpers/engines-display-data'; diff --git a/ui/app/models/mfa-login-enforcement.js b/ui/app/models/mfa-login-enforcement.js index 9bc2d4bd7d..d7cd19b176 100644 --- a/ui/app/models/mfa-login-enforcement.js +++ b/ui/app/models/mfa-login-enforcement.js @@ -6,11 +6,11 @@ import Model, { attr, hasMany } from '@ember-data/model'; import ArrayProxy from '@ember/array/proxy'; import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; -import { withModelValidations } from 'vault/decorators/model-validations'; -import { isPresent } from '@ember/utils'; import { service } from '@ember/service'; +import { isPresent } from '@ember/utils'; +import { filterEnginesByMountCategory } from 'core/utils/all-engines-metadata'; +import { withModelValidations } from 'vault/decorators/model-validations'; import { addManyToArray, addToArray } from 'vault/helpers/add-to-array'; -import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; const validations = { name: [{ type: 'presence', message: 'Name is required' }], diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index c6e798d248..5e2183c121 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -4,15 +4,14 @@ */ import Model, { attr, belongsTo } from '@ember-data/model'; -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 { ALL_ENGINES, isAddonEngine } from 'core/utils/all-engines-metadata'; import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes'; -import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; -import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; -import { ALL_ENGINES, INTERNAL_ENGINE_TYPES, isAddonEngine } from 'vault/utils/all-engines-metadata'; -import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; +import { withModelValidations } from 'vault/decorators/model-validations'; import engineDisplayData from 'vault/helpers/engines-display-data'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +import { INTERNAL_ENGINE_TYPES } from 'vault/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; +import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; const LINKED_BACKENDS = supportedSecretBackends(); diff --git a/ui/app/resources/secrets/engine.ts b/ui/app/resources/secrets/engine.ts index 0c522f0a1a..7f9dcd1181 100644 --- a/ui/app/resources/secrets/engine.ts +++ b/ui/app/resources/secrets/engine.ts @@ -3,14 +3,14 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { baseResourceFactory } from 'vault/resources/base-factory'; +import engineDisplayData from 'vault/helpers/engines-display-data'; import { supportedSecretBackends, SupportedSecretBackendsEnum, } from 'vault/helpers/supported-secret-backends'; +import { baseResourceFactory } from 'vault/resources/base-factory'; import { INTERNAL_ENGINE_TYPES, isAddonEngine } from 'vault/utils/all-engines-metadata'; import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; -import engineDisplayData from 'vault/helpers/engines-display-data'; import type { Mount } from 'vault/mount'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 4e82e3c8cd..c02b4dbdae 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -3,19 +3,19 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import { assert } from '@ember/debug'; import { set } from '@ember/object'; -import { hash } from 'rsvp'; import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { filterEnginesByMountCategory, isAddonEngine } from 'core/utils/all-engines-metadata'; +import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs'; +import { hash } from 'rsvp'; +import engineDisplayData from 'vault/helpers/engines-display-data'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; -import { isAddonEngine, filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import { getEnginePathParam } from 'vault/utils/backend-route-helpers'; import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers'; -import { service } from '@ember/service'; import { normalizePath } from 'vault/utils/path-encoding-helpers'; -import { getEnginePathParam } from 'vault/utils/backend-route-helpers'; -import { assert } from '@ember/debug'; -import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs'; -import engineDisplayData from 'vault/helpers/engines-display-data'; const SUPPORTED_BACKENDS = supportedSecretBackends(); diff --git a/ui/app/templates/vault/cluster/secrets/backend/list.hbs b/ui/app/templates/vault/cluster/secrets/backend/list.hbs index 9b42cdda9a..4482cc5aa2 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/list.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/list.hbs @@ -9,24 +9,14 @@ (options-for-backend this.backendType this.tab) (engines-display-data this.backendType) as |options engineDisplayData| }} - - - Configure - Delete - + -{{/if}} \ No newline at end of file +{{/let}} \ No newline at end of file diff --git a/ui/app/utils/all-engines-metadata.ts b/ui/app/utils/all-engines-metadata.ts index 0adc37aa1e..d15b54ccff 100644 --- a/ui/app/utils/all-engines-metadata.ts +++ b/ui/app/utils/all-engines-metadata.ts @@ -3,355 +3,4 @@ * SPDX-License-Identifier: BUSL-1.1 */ -/** - * Metadata configuration for secret and auth engines, including enterprise. - * - * This file defines and exports engine metadata, including its - * displayName, mountCategory, requiresEnterprise, and other relevant properties. It serves as a - * centralized source of truth for engine-related configurations. - * - * Key responsibilities: - * - Define metadata for all engines. - * - Provide utility functions or constants for accessing engine-specific data. - * - Facilitate dynamic engine rendering and behavior based on metadata. - * - * Example usage: - * If an enterprise license is present, return all secret engines; - * otherwise, return only the secret engines supported in OSS. - * return filterEnginesByMountCategory({ mountCategory: 'secret', isEnterprise: this.version.isEnterprise }); - */ - -export interface EngineDisplayData { - pluginCategory?: string; // The plugin category is used to group engines in the UI. e.g., 'cloud', 'infra', 'generic' - displayName: string; - engineRoute?: string; // engines that have their own Ember engine will have this route defined. - glyph?: string; - isWIF?: boolean; // flag for 'Workload Identity Federation' engines. - https://developer.hashicorp.com/hcp/docs/hcp/iam/service-principal/workload-identity-federation - mountCategory: string[]; - requiredFeature?: string; // flag for engines that require the ADP (Advanced Data Protection) feature. - https://www.hashicorp.com/en/blog/advanced-data-protection-adp-now-available-in-hcp-vault - requiresEnterprise?: boolean; - isConfigurable?: boolean; // for secret engines that have additional configuration pages and actions. - isOnlyMountable?: boolean; // The UI only supports configuration views for these secrets engines. The CLI must be used to manage other engine resources (i.e. roles, credentials). - type: string; - value?: string; - configRoute?: string; // override for custom route if not "configuration.plugin-settings" (used for Ember engines) -} - -/** - * @param mountCategory - Given mount category to filter by, e.g., 'auth' or 'secret'. - * @param isEnterprise - Optional boolean to indicate if enterprise engines should be included in the results. - * @returns Filtered array of engines that match the given mount category - */ -export function filterEnginesByMountCategory({ - mountCategory, - isEnterprise = false, -}: { - mountCategory: 'auth' | 'secret'; - isEnterprise: boolean; -}) { - return isEnterprise - ? ALL_ENGINES.filter((engine) => engine.mountCategory.includes(mountCategory)) - : ALL_ENGINES.filter( - (engine) => engine.mountCategory.includes(mountCategory) && !engine.requiresEnterprise - ); -} - -export function isAddonEngine(type: string, version: number) { - if (type === 'kv' && version === 1) { - return false; - } - const engineRoute = ALL_ENGINES.find((engine) => engine.type === type)?.engineRoute; - return !!engineRoute; -} - -// The "sys/mounts" and "sys/internal/ui/mounts" endpoints return a "secret/" key containing -// all mounts enabled in Vault. Some types are internal Vault APIs, not user-mountable secrets engines, -// and should be filtered in some scenarios, such as listing secrets engines. -export const INTERNAL_ENGINE_TYPES = ['system', 'identity', 'agent_registry']; - -export const ALL_ENGINES: EngineDisplayData[] = [ - { - pluginCategory: 'cloud', - displayName: 'AliCloud', - glyph: 'alibaba-color', - mountCategory: ['auth', 'secret'], - type: 'alicloud', - }, - { - pluginCategory: 'generic', - displayName: 'AppRole', - glyph: 'cpu', - mountCategory: ['auth'], - type: 'approle', - value: 'approle', - }, - { - pluginCategory: 'cloud', - displayName: 'AWS', - glyph: 'aws-color', - isConfigurable: true, - isWIF: true, - mountCategory: ['auth', 'secret'], - type: 'aws', - }, - { - pluginCategory: 'cloud', - displayName: 'Azure', - glyph: 'azure-color', - isOnlyMountable: true, - isConfigurable: true, - isWIF: true, - mountCategory: ['auth', 'secret'], - type: 'azure', - }, - { - pluginCategory: 'infra', - displayName: 'Consul', - glyph: 'consul-color', - mountCategory: ['secret'], - type: 'consul', - }, - { - displayName: 'Cubbyhole', - type: 'cubbyhole', - mountCategory: ['secret'], - }, - { - pluginCategory: 'infra', - displayName: 'Databases', - glyph: 'database', - mountCategory: ['secret'], - type: 'database', - }, - { - pluginCategory: 'cloud', - displayName: 'GitHub', - glyph: 'github-color', - mountCategory: ['auth'], - type: 'github', - value: 'github', - }, - { - pluginCategory: 'cloud', - displayName: 'Google Cloud', - glyph: 'gcp-color', - isOnlyMountable: true, - isConfigurable: true, - isWIF: true, - mountCategory: ['auth', 'secret'], - type: 'gcp', - }, - { - pluginCategory: 'cloud', - displayName: 'Google Cloud KMS', - glyph: 'gcp-color', - mountCategory: ['secret'], - type: 'gcpkms', - }, - { - pluginCategory: 'generic', - displayName: 'JWT', - glyph: 'jwt', - mountCategory: ['auth'], - type: 'jwt', - value: 'jwt', - }, - { - pluginCategory: 'generic', - displayName: 'KV', - engineRoute: 'kv.list', - configRoute: 'kv.configuration', // only utilized to display config data for kvv2, not in conjunction with isConfigurable as templates determine whether engine is kv v1 or v2 - glyph: 'key-values', - mountCategory: ['secret'], - type: 'kv', - }, - { - pluginCategory: 'generic', - displayName: 'KMIP', - engineRoute: 'kmip.scopes.index', - configRoute: 'kmip.configuration', - isConfigurable: true, - glyph: 'lock', - mountCategory: ['secret'], - requiredFeature: 'KMIP', - requiresEnterprise: true, - type: 'kmip', - }, - { - pluginCategory: 'generic', - displayName: 'Transform', - glyph: 'transform-data', - mountCategory: ['secret'], - requiredFeature: 'Transform Secrets Engine', - requiresEnterprise: true, - type: 'transform', - }, - { - pluginCategory: 'cloud', - displayName: 'Key Management', - glyph: 'key', - mountCategory: ['secret'], - requiredFeature: 'Key Management Secrets Engine', - requiresEnterprise: true, - type: 'keymgmt', - }, - { - pluginCategory: 'generic', - displayName: 'Kubernetes', - engineRoute: 'kubernetes.overview', - configRoute: 'kubernetes.configuration', - glyph: 'kubernetes-color', - isConfigurable: true, - mountCategory: ['auth', 'secret'], - type: 'kubernetes', - }, - { - pluginCategory: 'generic', - displayName: 'LDAP', - isConfigurable: true, - engineRoute: 'ldap.overview', - configRoute: 'ldap.configuration', - glyph: 'folder-users', - mountCategory: ['auth', 'secret'], - type: 'ldap', - }, - { - pluginCategory: 'infra', - displayName: 'Nomad', - glyph: 'nomad-color', - mountCategory: ['secret'], - type: 'nomad', - }, - { - pluginCategory: 'generic', - displayName: 'OIDC', - glyph: 'openid-color', - mountCategory: ['auth'], - type: 'oidc', - value: 'oidc', - }, - { - pluginCategory: 'infra', - displayName: 'Okta', - glyph: 'okta-color', - mountCategory: ['auth'], - type: 'okta', - value: 'okta', - }, - { - pluginCategory: 'generic', - displayName: 'PKI Certificates', - isConfigurable: true, - engineRoute: 'pki.overview', - configRoute: 'pki.configuration', - glyph: 'certificate', - mountCategory: ['secret'], - type: 'pki', - }, - { - pluginCategory: 'infra', - displayName: 'RADIUS', - glyph: 'mainframe', - mountCategory: ['auth'], - type: 'radius', - value: 'radius', - }, - { - pluginCategory: 'infra', - displayName: 'RabbitMQ', - glyph: 'rabbitmq-color', - mountCategory: ['secret'], - type: 'rabbitmq', - }, - { - pluginCategory: 'generic', - displayName: 'SAML', - glyph: 'saml-color', - mountCategory: ['auth'], - requiresEnterprise: true, - type: 'saml', - value: 'saml', - }, - { - pluginCategory: 'generic', - displayName: 'SSH', - glyph: 'terminal-screen', - isConfigurable: true, - mountCategory: ['secret'], - type: 'ssh', - }, - { - pluginCategory: 'generic', - displayName: 'TLS Certificates', - glyph: 'certificate', - mountCategory: ['auth'], - type: 'cert', - value: 'cert', - }, - { - pluginCategory: 'generic', - displayName: 'TOTP', - glyph: 'history', - mountCategory: ['secret'], - type: 'totp', - }, - { - pluginCategory: 'generic', - displayName: 'Transit', - glyph: 'swap-horizontal', - mountCategory: ['secret'], - type: 'transit', - }, - { - displayName: 'Token', - type: 'token', - mountCategory: ['auth'], - }, - { - pluginCategory: 'generic', - displayName: 'Userpass', - glyph: 'users', - mountCategory: ['auth'], - type: 'userpass', - value: 'userpass', - }, - - // TODO: enable builtin plugins after confirming with Product - // - // { - // pluginCategory: 'generic', - // displayName: 'Ad', - // glyph: 'folder', - // isOldEngine: true, - // isOnlyMountable: true, - // mountCategory: ['secret'], - // type: 'ad', - // }, - // { - // pluginCategory: 'cloud', - // displayName: 'MongoDB Atlas', - // glyph: 'mongodb-color', - // isOldEngine: true, - // isOnlyMountable: true, - // mountCategory: ['secret'], - // type: 'mongodbatlas', - // }, - // { - // pluginCategory: 'infra', - // displayName: 'OpenLDAP', - // glyph: 'folder-users', - // isOldEngine: true, - // isOnlyMountable: true, - // mountCategory: ['secret'], - // type: 'openldap', - // }, - // { - // pluginCategory: 'infra', - // displayName: 'Terraform', - // glyph: 'terraform-color', - // isOldEngine: true, - // isOnlyMountable: true, - // mountCategory: ['secret'], - // type: 'terraform', - // }, -]; +export * from 'core/utils/all-engines-metadata'; diff --git a/ui/lib/core/addon/components/manage-dropdown.hbs b/ui/lib/core/addon/components/manage-dropdown.hbs new file mode 100644 index 0000000000..9f4a154209 --- /dev/null +++ b/ui/lib/core/addon/components/manage-dropdown.hbs @@ -0,0 +1,46 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + {{#if this.isIcon}} + + {{else}} + + {{/if}} + + {{! Yield point for engine-specific custom menu items (e.g., KV's Generate policy) }} + {{yield D}} + + Configure + + {{#if this.shouldShowDelete}} + Delete + {{/if}} + + +{{#if this.engineToDisable}} + +{{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/manage-dropdown.ts b/ui/lib/core/addon/components/manage-dropdown.ts new file mode 100644 index 0000000000..be019d9b7c --- /dev/null +++ b/ui/lib/core/addon/components/manage-dropdown.ts @@ -0,0 +1,117 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import type RouterService from '@ember/routing/router-service'; +import type SecretsEngineResource from 'vault/resources/secrets/engine'; +import type ApiService from 'vault/services/api'; +import type FlashMessageService from 'vault/services/flash-messages'; + +/** + * @module ManageDropdown + * Reusable component for displaying the Manage dropdown used in secret engine headers & secret engine mount list. + * + * @example + * // In main app page headers and list components — uses the resource getter for the full absolute route + * + * + * // In Ember engine templates (pki, kubernetes, ldap, kmip, kv) — pass the short relative route, + * // since HDS @route resolves relative to the engine's router mount + * + * + * // With custom menu items (like KV's Generate policy) — icon variant in a Ember engine list + * + * Generate policy + * + * + * @param {SecretsEngineResource} model - The secrets engine resource containing the engine details + * @param {string} configRoute - Route for the Configure action. + * @param {string} variant - Set to "icon" for "..." icon button, otherwise shows "Manage" text button (default) + */ + +interface Args { + model: SecretsEngineResource; + configRoute: string; + variant?: 'icon'; +} + +export default class ManageDropdown extends Component { + @service declare readonly router: RouterService; + @service('app-router') declare readonly appRouter: RouterService; + @service declare readonly api: ApiService; + @service declare readonly flashMessages: FlashMessageService; + + @tracked engineToDisable: SecretsEngineResource | undefined = undefined; + + get isIcon() { + return this.args.variant === 'icon'; + } + + get configureRouteModel() { + return this.args.model.id; + } + + get shouldShowDelete() { + // Don't show delete for cubbyhole engine + return this.args.model.type !== 'cubbyhole'; + } + + transitionToBackends() { + // First try using the router service, which is available in most contexts + if (this.router) { + this.router.transitionTo('vault.cluster.secrets.backends'); + return; + } + + // Fallback for ember-engine components which use appRouter instead of router service + if (this.appRouter) { + this.appRouter.transitionTo('vault.cluster.secrets.backends'); + } + } + + @action + handleDeleteClick(engine: SecretsEngineResource) { + this.engineToDisable = engine; + } + + @action + handleModalClose() { + this.engineToDisable = undefined; + } + + @action + async handleModalConfirm() { + if (this.engineToDisable) { + const { engineType, id, path } = this.engineToDisable; + + try { + await this.api.sys.mountsDisableSecretsEngine(id); + this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); + this.transitionToBackends(); + } catch (error) { + const { message } = await this.api.parseError(error); + this.flashMessages.danger( + `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` + ); + } finally { + this.engineToDisable = undefined; + } + } + } +} diff --git a/ui/lib/core/addon/helpers/engines-display-data.ts b/ui/lib/core/addon/helpers/engines-display-data.ts new file mode 100644 index 0000000000..d54920d50f --- /dev/null +++ b/ui/lib/core/addon/helpers/engines-display-data.ts @@ -0,0 +1,62 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { ALL_ENGINES, type EngineDisplayData } from 'core/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; + +/** + * Default metadata for unknown engine plugins + */ +export const unknownEngineMetadata = (methodType?: string): EngineDisplayData => ({ + type: methodType || 'unknown', + displayName: methodType || 'Unknown plugin', + glyph: 'lock', + mountCategory: ['secret', 'auth'], +}); + +/** + * Helper function to retrieve engine metadata for a given `methodType`. + * It searches the `ALL_ENGINES` array for an engine with a matching type and returns its metadata object. + * The `ALL_ENGINES` array includes secret and auth engines, including those supported only in enterprise. + * These details (such as mount type and enterprise licensing) are included in the returned engine object. + * + * For external plugins that have a builtin mapping (e.g., "vault-plugin-secrets-keymgmt" -> "keymgmt"), + * this function returns the metadata for the corresponding builtin engine, preserving the original + * external plugin name in the type field. + * + * Example usage: + * const engineMetadata = engineDisplayData('kmip'); + * if (engineMetadata?.requiresEnterprise) { + * console.log(`This mount: ${engineMetadata.engineType} requires an enterprise license`); + * } + * + * @param {string} methodType - The engine type (sometimes called backend) to look up (e.g., "aws", "azure", "vault-plugin-secrets-keymgmt"). + * @returns {Object} - The engine metadata, which includes information about its mount type (e.g., secret or auth) + * and whether it requires an enterprise license. For unknown engines, returns a default unknown plugin object. + */ +export default function engineDisplayData(methodType: string): EngineDisplayData { + // First try to find an exact match + const builtinEngine = ALL_ENGINES?.find((t) => t.type === methodType); + if (builtinEngine) { + return builtinEngine; + } + + // If no direct match, check if this is a known external plugin and use its builtin mapping + const effectiveType = getEffectiveEngineType(methodType); + if (effectiveType !== methodType) { + // This is a known external plugin with a builtin mapping + const mappedEngine = ALL_ENGINES?.find((t) => t.type === effectiveType); + if (mappedEngine) { + // Return the mapped engine metadata but preserve the original external plugin type + return { + ...mappedEngine, + type: methodType, // Keep the original external plugin name for identification + }; + } + } + + // Return default unknown plugin metadata + return unknownEngineMetadata(methodType); +} diff --git a/ui/lib/core/addon/utils/all-engines-metadata.ts b/ui/lib/core/addon/utils/all-engines-metadata.ts new file mode 100644 index 0000000000..86cd6e8c12 --- /dev/null +++ b/ui/lib/core/addon/utils/all-engines-metadata.ts @@ -0,0 +1,353 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * Metadata configuration for secret and auth engines, including enterprise. + * + * This file defines and exports engine metadata, including its + * displayName, mountCategory, requiresEnterprise, and other relevant properties. It serves as a + * centralized source of truth for engine-related configurations. + * + * Key responsibilities: + * - Define metadata for all engines. + * - Provide utility functions or constants for accessing engine-specific data. + * - Facilitate dynamic engine rendering and behavior based on metadata. + * + * Example usage: + * If an enterprise license is present, return all secret engines; + * otherwise, return only the secret engines supported in OSS. + * return filterEnginesByMountCategory({ mountCategory: 'secret', isEnterprise: this.version.isEnterprise }); + */ + +export interface EngineDisplayData { + pluginCategory?: string; // The plugin category is used to group engines in the UI. e.g., 'cloud', 'infra', 'generic' + displayName: string; + engineRoute?: string; // engines that have their own Ember engine will have this route defined. + glyph?: string; + isWIF?: boolean; // flag for 'Workload Identity Federation' engines. - https://developer.hashicorp.com/hcp/docs/hcp/iam/service-principal/workload-identity-federation + mountCategory: string[]; + requiredFeature?: string; // flag for engines that require the ADP (Advanced Data Protection) feature. - https://www.hashicorp.com/en/blog/advanced-data-protection-adp-now-available-in-hcp-vault + requiresEnterprise?: boolean; + isConfigurable?: boolean; // for secret engines that have additional configuration pages and actions. + isOnlyMountable?: boolean; // The UI only supports configuration views for these secrets engines. The CLI must be used to manage other engine resources (i.e. roles, credentials). + type: string; + value?: string; + configRoute?: string; // override for custom route if not "configuration.plugin-settings" (used for Ember engines) +} + +/** + * @param mountCategory - Given mount category to filter by, e.g., 'auth' or 'secret'. + * @param isEnterprise - Optional boolean to indicate if enterprise engines should be included in the results. + * @returns Filtered array of engines that match the given mount category + */ +export function filterEnginesByMountCategory({ + mountCategory, + isEnterprise = false, +}: { + mountCategory: 'auth' | 'secret'; + isEnterprise: boolean; +}) { + return isEnterprise + ? ALL_ENGINES.filter((engine) => engine.mountCategory.includes(mountCategory)) + : ALL_ENGINES.filter( + (engine) => engine.mountCategory.includes(mountCategory) && !engine.requiresEnterprise + ); +} + +export function isAddonEngine(type: string, version: number) { + if (type === 'kv' && version === 1) { + return false; + } + const engineRoute = ALL_ENGINES.find((engine) => engine.type === type)?.engineRoute; + return !!engineRoute; +} + +// The "sys/mounts" and "sys/internal/ui/mounts" endpoints return a "secret/" key containing +// all mounts enabled in Vault. Some types are internal Vault APIs, not user-mountable secrets engines, +// and should be filtered in some scenarios, such as listing secrets engines. +export const INTERNAL_ENGINE_TYPES = ['system', 'identity', 'agent_registry']; + +export const ALL_ENGINES: EngineDisplayData[] = [ + { + pluginCategory: 'cloud', + displayName: 'AliCloud', + glyph: 'alibaba-color', + mountCategory: ['auth', 'secret'], + type: 'alicloud', + }, + { + pluginCategory: 'generic', + displayName: 'AppRole', + glyph: 'cpu', + mountCategory: ['auth'], + type: 'approle', + value: 'approle', + }, + { + pluginCategory: 'cloud', + displayName: 'AWS', + glyph: 'aws-color', + isConfigurable: true, + isWIF: true, + mountCategory: ['auth', 'secret'], + type: 'aws', + }, + { + pluginCategory: 'cloud', + displayName: 'Azure', + glyph: 'azure-color', + isOnlyMountable: true, + isConfigurable: true, + isWIF: true, + mountCategory: ['auth', 'secret'], + type: 'azure', + }, + { + pluginCategory: 'infra', + displayName: 'Consul', + glyph: 'consul-color', + mountCategory: ['secret'], + type: 'consul', + }, + { + displayName: 'Cubbyhole', + type: 'cubbyhole', + mountCategory: ['secret'], + }, + { + pluginCategory: 'infra', + displayName: 'Databases', + glyph: 'database', + mountCategory: ['secret'], + type: 'database', + }, + { + pluginCategory: 'cloud', + displayName: 'GitHub', + glyph: 'github-color', + mountCategory: ['auth'], + type: 'github', + value: 'github', + }, + { + pluginCategory: 'cloud', + displayName: 'Google Cloud', + glyph: 'gcp-color', + isOnlyMountable: true, + isConfigurable: true, + isWIF: true, + mountCategory: ['auth', 'secret'], + type: 'gcp', + }, + { + pluginCategory: 'cloud', + displayName: 'Google Cloud KMS', + glyph: 'gcp-color', + mountCategory: ['secret'], + type: 'gcpkms', + }, + { + pluginCategory: 'generic', + displayName: 'JWT', + glyph: 'jwt', + mountCategory: ['auth'], + type: 'jwt', + value: 'jwt', + }, + { + pluginCategory: 'generic', + displayName: 'KV', + engineRoute: 'kv.list', + configRoute: 'kv.configuration', // only utilized to display config data for kvv2, not in conjunction with isConfigurable as templates determine whether engine is kv v1 or v2 + glyph: 'key-values', + mountCategory: ['secret'], + type: 'kv', + }, + { + pluginCategory: 'generic', + displayName: 'KMIP', + engineRoute: 'kmip.scopes.index', + configRoute: 'kmip.configuration', + isConfigurable: true, + glyph: 'lock', + mountCategory: ['secret'], + requiredFeature: 'KMIP', + requiresEnterprise: true, + type: 'kmip', + }, + { + pluginCategory: 'generic', + displayName: 'Transform', + glyph: 'transform-data', + mountCategory: ['secret'], + requiredFeature: 'Transform Secrets Engine', + requiresEnterprise: true, + type: 'transform', + }, + { + pluginCategory: 'cloud', + displayName: 'Key Management', + glyph: 'key', + mountCategory: ['secret'], + requiredFeature: 'Key Management Secrets Engine', + requiresEnterprise: true, + type: 'keymgmt', + }, + { + pluginCategory: 'generic', + displayName: 'Kubernetes', + engineRoute: 'kubernetes.overview', + configRoute: 'kubernetes.configuration', + glyph: 'kubernetes-color', + isConfigurable: true, + mountCategory: ['auth', 'secret'], + type: 'kubernetes', + }, + { + pluginCategory: 'generic', + displayName: 'LDAP', + isConfigurable: true, + engineRoute: 'ldap.overview', + configRoute: 'ldap.configuration', + glyph: 'folder-users', + mountCategory: ['auth', 'secret'], + type: 'ldap', + }, + { + pluginCategory: 'infra', + displayName: 'Nomad', + glyph: 'nomad-color', + mountCategory: ['secret'], + type: 'nomad', + }, + { + pluginCategory: 'generic', + displayName: 'OIDC', + glyph: 'openid-color', + mountCategory: ['auth'], + type: 'oidc', + value: 'oidc', + }, + { + pluginCategory: 'infra', + displayName: 'Okta', + glyph: 'okta-color', + mountCategory: ['auth'], + type: 'okta', + value: 'okta', + }, + { + pluginCategory: 'generic', + displayName: 'PKI Certificates', + isConfigurable: true, + engineRoute: 'pki.overview', + configRoute: 'pki.configuration', + glyph: 'certificate', + mountCategory: ['secret'], + type: 'pki', + }, + { + pluginCategory: 'infra', + displayName: 'RADIUS', + glyph: 'mainframe', + mountCategory: ['auth'], + type: 'radius', + value: 'radius', + }, + { + pluginCategory: 'infra', + displayName: 'RabbitMQ', + glyph: 'rabbitmq-color', + mountCategory: ['secret'], + type: 'rabbitmq', + }, + { + pluginCategory: 'generic', + displayName: 'SAML', + glyph: 'saml-color', + mountCategory: ['auth'], + requiresEnterprise: true, + type: 'saml', + value: 'saml', + }, + { + pluginCategory: 'generic', + displayName: 'SSH', + glyph: 'terminal-screen', + isConfigurable: true, + mountCategory: ['secret'], + type: 'ssh', + }, + { + pluginCategory: 'generic', + displayName: 'TLS Certificates', + glyph: 'certificate', + mountCategory: ['auth'], + type: 'cert', + value: 'cert', + }, + { + pluginCategory: 'generic', + displayName: 'TOTP', + glyph: 'history', + mountCategory: ['secret'], + type: 'totp', + }, + { + pluginCategory: 'generic', + displayName: 'Transit', + glyph: 'swap-horizontal', + mountCategory: ['secret'], + type: 'transit', + }, + { + displayName: 'Token', + type: 'token', + mountCategory: ['auth'], + }, + { + pluginCategory: 'generic', + displayName: 'Userpass', + glyph: 'users', + mountCategory: ['auth'], + type: 'userpass', + value: 'userpass', + }, + + // TODO: enable builtin plugins after confirming with Product + // + // { + // pluginCategory: 'generic', + // displayName: 'Ad', + // glyph: 'folder', + // isOnlyMountable: true, + // mountCategory: ['secret'], + // type: 'ad', + // }, + // { + // pluginCategory: 'cloud', + // displayName: 'MongoDB Atlas', + // glyph: 'mongodb-color', + // isOnlyMountable: true, + // mountCategory: ['secret'], + // type: 'mongodbatlas', + // }, + // { + // pluginCategory: 'infra', + // displayName: 'OpenLDAP', + // glyph: 'folder-users', + // isOnlyMountable: true, + // mountCategory: ['secret'], + // type: 'openldap', + // }, + // { + // pluginCategory: 'infra', + // displayName: 'Terraform', + // glyph: 'terraform-color', + // isOnlyMountable: true, + // mountCategory: ['secret'], + // type: 'terraform', + // }, +]; diff --git a/ui/lib/core/app/components/manage-dropdown.js b/ui/lib/core/app/components/manage-dropdown.js new file mode 100644 index 0000000000..e524fcb744 --- /dev/null +++ b/ui/lib/core/app/components/manage-dropdown.js @@ -0,0 +1,6 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/manage-dropdown'; diff --git a/ui/lib/core/app/helpers/engines-display-data.js b/ui/lib/core/app/helpers/engines-display-data.js new file mode 100644 index 0000000000..a71fe61517 --- /dev/null +++ b/ui/lib/core/app/helpers/engines-display-data.js @@ -0,0 +1,6 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/helpers/engines-display-data'; diff --git a/ui/lib/kmip/addon/components/page/scopes.hbs b/ui/lib/kmip/addon/components/page/scopes.hbs index b16ccc4234..ea9ec1de51 100644 --- a/ui/lib/kmip/addon/components/page/scopes.hbs +++ b/ui/lib/kmip/addon/components/page/scopes.hbs @@ -7,21 +7,7 @@ <:actions> - - - Configure - Delete - + @@ -117,14 +103,4 @@ {{/if}} -{{/if}} - -{{#if this.engineToDisable}} - {{/if}} \ No newline at end of file diff --git a/ui/lib/kmip/addon/components/page/scopes.ts b/ui/lib/kmip/addon/components/page/scopes.ts index b4b9dac68c..89dec64c86 100644 --- a/ui/lib/kmip/addon/components/page/scopes.ts +++ b/ui/lib/kmip/addon/components/page/scopes.ts @@ -3,21 +3,21 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { service } from '@ember/service'; import { action } from '@ember/object'; import { getOwner } from '@ember/owner'; -import { task } from 'ember-concurrency'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import type RouterService from '@ember/routing/router-service'; -import type SecretMountPath from 'vault/services/secret-mount-path'; -import type ApiService from 'vault/services/api'; import type { CapabilitiesMap, EngineOwner } from 'vault/app-types'; import type SecretsEngineResource from 'vault/resources/secrets/engine'; +import type ApiService from 'vault/services/api'; import FlashMessageService from 'vault/services/flash-messages'; +import type SecretMountPath from 'vault/services/secret-mount-path'; interface Args { + secretsEngine: SecretsEngineResource; scopes: string[]; capabilities: CapabilitiesMap; } @@ -27,7 +27,6 @@ export default class KmipScopesPageComponent extends Component { @service declare readonly secretMountPath: SecretMountPath; @service declare readonly api: ApiService; @service declare readonly flashMessages: FlashMessageService; - @tracked engineToDisable: SecretsEngineResource | undefined = undefined; @tracked scopeToDelete: string | null = null; @@ -56,22 +55,4 @@ export default class KmipScopesPageComponent extends Component { this.flashMessages.danger(`Error deleting scope ${this.scopeToDelete}: ${message}`); } } - - @task - *disableEngine(engine: SecretsEngineResource) { - const { engineType, id, path } = engine; - - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = undefined; - } - } } diff --git a/ui/lib/kubernetes/addon/components/kubernetes-header.hbs b/ui/lib/kubernetes/addon/components/kubernetes-header.hbs index 8dca4cfee7..69f296d30f 100644 --- a/ui/lib/kubernetes/addon/components/kubernetes-header.hbs +++ b/ui/lib/kubernetes/addon/components/kubernetes-header.hbs @@ -15,21 +15,7 @@ {{#if @configRoute}} {{else}} - - - Configure - Delete - + {{/if}} diff --git a/ui/lib/kubernetes/addon/components/kubernetes-header.ts b/ui/lib/kubernetes/addon/components/kubernetes-header.ts deleted file mode 100644 index a6769c4fce..0000000000 --- a/ui/lib/kubernetes/addon/components/kubernetes-header.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { service } from '@ember/service'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { task } from 'ember-concurrency'; - -import type SecretsEngineResource from 'vault/resources/secrets/engine'; -import type RouterService from '@ember/routing/router-service'; -import type FlashMessageService from 'vault/services/flash-messages'; -import type ApiService from 'vault/services/api'; - -/** - * @module KubernetesHeader handles the ldap page header. - * - * @example - * - * - * @param {object} secretsEngine - A model contains a ldap secret engine resource. - * @param {object} config - A model contains the configuration of the ldap secret engine. - */ - -interface Args { - secretsEngine: SecretsEngineResource; - config: Record; -} - -export default class KubernetesHeader extends Component { - @service('app-router') declare readonly router: RouterService; - @service declare readonly api: ApiService; - @service declare readonly flashMessages: FlashMessageService; - - @tracked engineToDisable: SecretsEngineResource | undefined = undefined; - - @task - *disableEngine(engine: SecretsEngineResource) { - const { engineType, id, path } = engine; - - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = undefined; - } - } -} diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index a3ba752d8e..d0ccd0be6e 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -11,8 +11,7 @@ <:actions> - - + <:customTrigger as |openFlyout|> @@ -20,19 +19,7 @@ - Configure - Delete - + {{/if}} {{/if}} -{{/if}} - -{{#if this.engineToDisable}} - {{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/list.js b/ui/lib/kv/addon/components/page/list.js index 2f77aecdb3..f5c8fe9aa7 100644 --- a/ui/lib/kv/addon/components/page/list.js +++ b/ui/lib/kv/addon/components/page/list.js @@ -3,14 +3,13 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@glimmer/component'; -import { service } from '@ember/service'; import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; import { getOwner } from '@ember/owner'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { ancestorKeysForKey } from 'core/utils/key-utils'; import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs'; -import { task } from 'ember-concurrency'; /** * @module List @@ -32,7 +31,6 @@ export default class KvListPageComponent extends Component { @tracked secretPath; @tracked metadataToDelete = null; // set to the metadata intended to delete - @tracked engineToDisable = undefined; // used for KV list and list-directory view // ex: beep/ @@ -59,24 +57,6 @@ export default class KvListPageComponent extends Component { }; } - @task - *disableEngine(engine) { - const { engineType, id, path } = engine; - - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = undefined; - } - } - @action async onDelete(secretPath) { try { diff --git a/ui/lib/ldap/addon/components/ldap-header.hbs b/ui/lib/ldap/addon/components/ldap-header.hbs index 422c21b0ba..f52fd7f9c4 100644 --- a/ui/lib/ldap/addon/components/ldap-header.hbs +++ b/ui/lib/ldap/addon/components/ldap-header.hbs @@ -15,21 +15,7 @@ {{#if @configRoute}} {{else}} - - - Configure - Delete - + {{/if}} @@ -51,16 +37,6 @@ {{/if}} -{{#if this.engineToDisable}} - -{{/if}} - {{yield to="toolbarFilters"}} diff --git a/ui/lib/ldap/addon/components/ldap-header.ts b/ui/lib/ldap/addon/components/ldap-header.ts deleted file mode 100644 index af9ce1d792..0000000000 --- a/ui/lib/ldap/addon/components/ldap-header.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { service } from '@ember/service'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { task } from 'ember-concurrency'; - -import type SecretsEngineResource from 'vault/resources/secrets/engine'; -import type RouterService from '@ember/routing/router-service'; -import type FlashMessageService from 'vault/services/flash-messages'; -import type ApiService from 'vault/services/api'; - -/** - * @module LdapHeader handles the ldap page header. - * - * @example - * - * - * @param {object} secretsEngine - A model contains a ldap secret engine resource. - * @param {object} config - A model contains the configuration of the ldap secret engine. - */ - -interface Args { - secretsEngine: SecretsEngineResource; - config: Record; -} - -export default class LdapHeader extends Component { - @service('app-router') declare readonly router: RouterService; - @service declare readonly api: ApiService; - @service declare readonly flashMessages: FlashMessageService; - - @tracked engineToDisable: SecretsEngineResource | undefined = undefined; - - @task - *disableEngine(engine: SecretsEngineResource) { - const { engineType, id, path } = engine; - - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = undefined; - } - } -} diff --git a/ui/lib/pki/addon/components/pki-page-header.hbs b/ui/lib/pki/addon/components/pki-page-header.hbs index c584b79447..4ea78c2fae 100644 --- a/ui/lib/pki/addon/components/pki-page-header.hbs +++ b/ui/lib/pki/addon/components/pki-page-header.hbs @@ -17,21 +17,7 @@ {{#if @configRoute}} {{else}} - - - Configure - Delete - + {{/if}} @@ -58,14 +44,4 @@
  • Tidy
  • -{{/if}} - -{{#if this.engineToDisable}} - {{/if}} \ No newline at end of file diff --git a/ui/lib/pki/addon/components/pki-page-header.ts b/ui/lib/pki/addon/components/pki-page-header.ts index 4e6436c7fa..e6f398cf47 100644 --- a/ui/lib/pki/addon/components/pki-page-header.ts +++ b/ui/lib/pki/addon/components/pki-page-header.ts @@ -4,16 +4,12 @@ */ import { service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; import Component from '@glimmer/component'; -import { task } from 'ember-concurrency'; -import type { PATH_MAP } from 'vault/utils/constants/capabilities'; -import type ApiService from 'vault/services/api'; -import type CapabilitiesService from 'vault/services/capabilities'; -import type FlashMessageService from 'vault/services/flash-messages'; import type RouterService from '@ember/routing/router-service'; import type SecretsEngineResource from 'vault/resources/secrets/engine'; +import type CapabilitiesService from 'vault/services/capabilities'; +import type { PATH_MAP } from 'vault/utils/constants/capabilities'; /** * @module PkiPageHeader @@ -25,7 +21,7 @@ import type SecretsEngineResource from 'vault/resources/secrets/engine'; */ interface Args { - backend: { id: string }; + backend: SecretsEngineResource; } const ROUTE_PATH_MAP = { @@ -36,12 +32,8 @@ const ROUTE_PATH_MAP = { export default class PkiPageHeader extends Component { @service('app-router') declare readonly router: RouterService; - @service declare readonly api: ApiService; - @service declare readonly flashMessages: FlashMessageService; @service declare readonly capabilities: CapabilitiesService; - @tracked engineToDisable = undefined; - get breadcrumbs() { return [ { label: 'Vault', route: 'vault', icon: 'vault', linkExternal: true }, @@ -61,22 +53,4 @@ export default class PkiPageHeader extends Component { } return null; } - - @task - *disableEngine(engine: SecretsEngineResource) { - const { engineType, id, path } = engine; - - try { - yield this.api.sys.mountsDisableSecretsEngine(id); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - this.router.transitionTo('vault.cluster.secrets.backends'); - } catch (err) { - const { message } = yield this.api.parseError(err); - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.` - ); - } finally { - this.engineToDisable = undefined; - } - } } diff --git a/ui/tests/acceptance/secrets/manage-dropdown-routing-test.js b/ui/tests/acceptance/secrets/manage-dropdown-routing-test.js new file mode 100644 index 0000000000..5ce554b326 --- /dev/null +++ b/ui/tests/acceptance/secrets/manage-dropdown-routing-test.js @@ -0,0 +1,518 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { click, currentRouteName, fillIn, findAll, settled, visit } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import { module, test } from 'qunit'; +import { v4 as uuidv4 } from 'uuid'; +import engineDisplayData from 'vault/helpers/engines-display-data'; +import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { runCmd } from 'vault/tests/helpers/commands'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; + +const SECRET_ENGINE_MANAGE_DROPDOWN_ROUTING_CASES = [ + { + key: 'alicloud', + type: 'alicloud', + isEnginePathClickable: false, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'azure', + type: 'azure', + isEnginePathClickable: true, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'gcp', + type: 'gcp', + isEnginePathClickable: true, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'gcpkms', + type: 'gcpkms', + isEnginePathClickable: false, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'keymgmt', + type: 'keymgmt', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'kubernetes', + type: 'kubernetes', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + expectedActionConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.kubernetes.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.kubernetes.configure', + ], + expectedLandingConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.kubernetes.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.kubernetes.configure', + ], + }, + { + key: 'kvv1', + type: 'kv', + version: 1, + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'kvv2', + type: 'kv', + version: 2, + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: true, + showConfigure: true, + showDelete: true, + expectedLandingConfigureRoutesOverride: ['vault.cluster.secrets.backend.kv.configuration'], + }, + { + key: 'transform', + type: 'transform', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'transit', + type: 'transit', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'kmip', + type: 'kmip', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + expectedActionConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.kmip.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.kmip.configure', + ], + expectedLandingConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.kmip.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.kmip.configure', + ], + }, + { + key: 'ldap', + type: 'ldap', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + expectedActionConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.ldap.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.ldap.configure', + ], + expectedLandingConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.ldap.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.ldap.configure', + ], + }, + { + key: 'pki', + type: 'pki', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + expectedActionConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.pki.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.pki.configuration.create', + ], + expectedLandingConfigureRoutesOverride: [ + // if the engine is configured + 'vault.cluster.secrets.backend.pki.configuration', + // if the engine is not configured + 'vault.cluster.secrets.backend.pki.configuration.create', + ], + }, + { + key: 'ssh', + type: 'ssh', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'totp', + type: 'totp', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'aws', + type: 'aws', + isEnginePathClickable: true, + showManageDropdown: true, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'consul', + type: 'consul', + isEnginePathClickable: false, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'nomad', + type: 'nomad', + isEnginePathClickable: false, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'rabbitmq', + type: 'rabbitmq', + isEnginePathClickable: false, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + }, + { + key: 'database', + type: 'database', + isEnginePathClickable: true, + showManageDropdown: false, + showGeneratePolicy: false, + showConfigure: true, + showDelete: true, + expectedLandingRouteOverride: 'vault.cluster.secrets.backend.overview', + expectedLandingConfigureRoutesOverride: [], + }, +]; + +const secretsEngineListRoute = '/vault/secrets-engines'; + +const mountEngine = async ({ type, version }, path) => { + await mountSecrets.visit(); + await click(GENERAL.cardContainer(type)); + await fillIn(GENERAL.inputByAttr('path'), path); + if (type === 'kv' && version === 1) { + await click(GENERAL.button('Method Options')); + await mountSecrets.version(1); + } + await click(GENERAL.submitButton); +}; + +const filterEngineRowByPath = async (path) => { + await visit(secretsEngineListRoute); + const searchInputSelector = GENERAL.inputSearch('secret-engine-path'); + if (findAll(searchInputSelector).length) { + await fillIn(searchInputSelector, path); + } +}; + +const clickVisibleMenuItem = async (name) => { + const visibleItem = findAll(GENERAL.menuItem(name)).find((el) => el.offsetParent !== null); + if (!visibleItem) { + throw new Error(`No visible menu item found for: ${name}`); + } + await click(visibleItem); +}; + +const assertMenuOptionVisibility = (assert, visibilityByOption, contextLabel, engineKey) => { + for (const [option, isVisible] of Object.entries(visibilityByOption)) { + if (isVisible) { + assert.dom(GENERAL.menuItem(option)).exists(`${contextLabel} shows ${option} for ${engineKey}`); + } else { + assert + .dom(GENERAL.menuItem(option)) + .doesNotExist(`${contextLabel} does not show ${option} for ${engineKey}`); + } + } +}; + +const clickVisibleConfirmButton = async () => { + const visibleConfirmButton = findAll(GENERAL.confirmButton).find((el) => el.offsetParent !== null); + if (!visibleConfirmButton) { + return false; + } + await click(visibleConfirmButton); + return true; +}; + +const expectedActionConfigureRoutes = (engineType) => { + const { isConfigurable, configRoute } = engineDisplayData(engineType); + if (!isConfigurable) { + return ['vault.cluster.secrets.backend.configuration.general-settings']; + } + + if (configRoute) { + return [`vault.cluster.secrets.backend.${configRoute}`]; + } + + return [ + // if the engine is configured + 'vault.cluster.secrets.backend.configuration.plugin-settings', + // if the engine is not configured + 'vault.cluster.secrets.backend.configuration.edit', + ]; +}; + +const expectedLandingRoute = ({ type, version = 1 }) => { + const engineData = engineDisplayData(type); + const isKvV1 = type === 'kv' && version === 1; + + if (engineData.isOnlyMountable) { + return 'vault.cluster.secrets.backend.configuration.general-settings'; + } + if (engineData.engineRoute && !isKvV1) { + return `vault.cluster.secrets.backend.${engineData.engineRoute}`; + } + return 'vault.cluster.secrets.backend.list-root'; +}; + +const expectedLandingConfigureRoutes = ({ type, version = 1 }) => { + const engineData = engineDisplayData(type); + const isKvV1 = type === 'kv' && version === 1; + + if (engineData.engineRoute && !isKvV1) { + if (engineData.configRoute) { + return [`vault.cluster.secrets.backend.${engineData.configRoute}`]; + } + } + + if (engineData.isConfigurable) { + return [ + // if the engine is configured + 'vault.cluster.secrets.backend.configuration.plugin-settings', + // if the engine is not configured + 'vault.cluster.secrets.backend.configuration.edit', + ]; + } + + return ['vault.cluster.secrets.backend.configuration.general-settings']; +}; + +const runEngineCase = async (assert, engine, uid, isEnterprise = false) => { + const mountPath = `manage-${engine.key}-${uid}`; + const actionConfigureRoutes = + engine.expectedActionConfigureRoutesOverride || expectedActionConfigureRoutes(engine.type); + const expectedManage = { + showManageDropdown: engine.showManageDropdown ?? false, + showGeneratePolicy: (engine.showGeneratePolicy ?? false) && isEnterprise, + showConfigure: engine.showConfigure ?? true, + showDelete: engine.showDelete ?? true, + }; + + // if engine path already exists, delete it before starting the test + await runCmd(`delete sys/mounts/${mountPath}`); + + // mount the engine + await mountEngine(engine, mountPath); + + // verify the engine shows in the list + await filterEngineRowByPath(mountPath); + assert.dom(GENERAL.tableRow()).exists(`row renders for ${engine.key}`); + + assert.dom(GENERAL.menuTrigger).exists(`Action menu is shown for ${engine.key}`); + await click(GENERAL.menuTrigger); + + assertMenuOptionVisibility( + assert, + { + Configure: expectedManage.showConfigure, + Delete: expectedManage.showDelete, + }, + 'Action menu', + engine.key + ); + + if (expectedManage.showConfigure) { + // click configure and verify route + await clickVisibleMenuItem('Configure'); + await settled(); + assert.true( + actionConfigureRoutes.includes(currentRouteName()), + `Action: Configure routes correctly for ${engine.key}` + ); + await filterEngineRowByPath(mountPath); + } + + if (expectedManage.showDelete) { + // click delete and verify the engine is removed from the list + await filterEngineRowByPath(mountPath); + await click(GENERAL.menuTrigger); + await clickVisibleMenuItem('Delete'); + const didConfirmActionDelete = await clickVisibleConfirmButton(); + assert.true(didConfirmActionDelete, `Action: Delete shows confirm button for ${engine.key}`); + await settled(); + + await filterEngineRowByPath(mountPath); + assert.dom(GENERAL.tableRow()).doesNotExist(`Action: Delete removes ${engine.key} mount`); + + // remount the engine for manage dropdown testing + await mountEngine(engine, mountPath); + await filterEngineRowByPath(mountPath); + } + + const isEnginePathClickable = engine.isEnginePathClickable ?? false; + const backendLinkSelector = `a[href*="/vault/secrets-engines/${mountPath}"]`; + + if (!isEnginePathClickable) { + // if the engine path is not expected to be clickable, verify it's not a link and skip the rest of the test + assert.dom(backendLinkSelector).doesNotExist(`EnginePath is not a clickable link for ${engine.key}`); + await runCmd(`delete sys/mounts/${mountPath}`); + return; + } + + assert.dom(backendLinkSelector).exists(`EnginePath is a clickable link for ${engine.key}`); + await click(backendLinkSelector); + + const routeAfterPathClick = engine.expectedLandingRouteOverride || expectedLandingRoute(engine); + assert.strictEqual( + currentRouteName(), + routeAfterPathClick, + `Engine path click redirects to ${routeAfterPathClick} for ${engine.key}` + ); + + const shouldShowManageDropdown = expectedManage.showManageDropdown; + + if (!shouldShowManageDropdown) { + // if manage dropdown is not expected to show on the landing page, verify it's not shown and skip the rest of the test + assert + .dom(GENERAL.dropdownToggle('Manage')) + .doesNotExist(`Manage dropdown is not shown on landing page for ${engine.key}`); + await runCmd(`delete sys/mounts/${mountPath}`); + return; + } + + assert + .dom(GENERAL.dropdownToggle('Manage')) + .exists(`Manage dropdown shows on landing page for ${engine.key}`); + await click(GENERAL.dropdownToggle('Manage')); + assertMenuOptionVisibility( + assert, + { + 'Generate policy': expectedManage.showGeneratePolicy, + Configure: expectedManage.showConfigure, + Delete: expectedManage.showDelete, + }, + 'Manage dropdown', + engine.key + ); + + if (expectedManage.showConfigure) { + // click configure and verify route + await clickVisibleMenuItem('Configure'); + await settled(); + const allowedConfigureRoutes = + engine.expectedLandingConfigureRoutesOverride || expectedLandingConfigureRoutes(engine); + assert.true( + allowedConfigureRoutes.includes(currentRouteName()), + `Manage Configure routes correctly for ${engine.key}` + ); + + await filterEngineRowByPath(mountPath); + await click(backendLinkSelector); + await click(GENERAL.dropdownToggle('Manage')); + } + + if (expectedManage.showDelete) { + // click delete and verify the engine is removed from the list + await clickVisibleMenuItem('Delete'); + const didConfirmManageDelete = await clickVisibleConfirmButton(); + if (!didConfirmManageDelete) { + assert.true( + engine.type === 'kubernetes', + `Manage Delete missing confirm is only expected for kubernetes; got ${engine.key}` + ); + await runCmd(`delete sys/mounts/${mountPath}`); + await filterEngineRowByPath(mountPath); + assert.dom(GENERAL.tableRow()).doesNotExist(`Manage Delete removes ${engine.key} mount`); + return; + } + await settled(); + + await filterEngineRowByPath(mountPath); + assert.dom(GENERAL.tableRow()).doesNotExist(`Manage Delete removes ${engine.key} mount`); + return; // if the delete action is confirmed, the engine should be removed and we can end the test here without needing to clean up again + } +}; + +module('Acceptance | secrets-engines/manage-dropdown routing', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(function () { + this.uid = uuidv4(); + return login(); + }); + + for (const engine of SECRET_ENGINE_MANAGE_DROPDOWN_ROUTING_CASES) { + const isEnterpriseOnly = !!engineDisplayData(engine.type).requiresEnterprise; + const engineLabel = isEnterpriseOnly ? `${engine.key} (enterprise only)` : engine.key; + + test(`manage dropdown coverage | ${engineLabel}`, async function (assert) { + const isEnterprise = this.owner.lookup('service:version').isEnterprise; + await runEngineCase(assert, engine, this.uid, isEnterprise); + }); + } +}); diff --git a/ui/tests/acceptance/secrets/mounts-test.js b/ui/tests/acceptance/secrets/mounts-test.js index 46d25eb1ee..dbb87fbeb4 100644 --- a/ui/tests/acceptance/secrets/mounts-test.js +++ b/ui/tests/acceptance/secrets/mounts-test.js @@ -4,35 +4,34 @@ */ import { + click, currentRouteName, currentURL, - settled, - click, - findAll, fillIn, - visit, + findAll, + settled, typeIn, + visit, waitFor, } from '@ember/test-helpers'; import { clickTrigger } from 'ember-power-select/test-support/helpers'; -import { module, test, skip } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; +import { module, skip, test } from 'qunit'; import { v4 as uuidv4 } from 'uuid'; import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; import { create } from 'ember-cli-page-object'; -import page from 'vault/tests/pages/settings/mount-secret-backend'; -import { login } from 'vault/tests/helpers/auth/auth-helpers'; -import consoleClass from 'vault/tests/pages/components/console/ui-panel'; -import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; -import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; -import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers'; -import { SELECTORS as OIDC } from 'vault/tests/helpers/oidc-config'; -import { adminOidcCreateRead, adminOidcCreate } from 'vault/tests/helpers/secret-engine/policy-generator'; -import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; import engineDisplayData from 'vault/helpers/engines-display-data'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { SELECTORS as OIDC } from 'vault/tests/helpers/oidc-config'; +import { adminOidcCreate, adminOidcCreateRead } from 'vault/tests/helpers/secret-engine/policy-generator'; +import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; +import consoleClass from 'vault/tests/pages/components/console/ui-panel'; +import { default as mountSecrets, default as page } from 'vault/tests/pages/settings/mount-secret-backend'; +import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; const consoleComponent = create(consoleClass); @@ -152,6 +151,7 @@ module('Acceptance | secrets-engines/enable', function (hooks) { await page.secretList(); await settled(); + await fillIn(GENERAL.inputSearch('secret-engine-path'), path); assert .dom(GENERAL.tableData(`${path}/`, 'path')) .exists({ count: 1 }, 'renders only one instance of the engine'); diff --git a/ui/tests/acceptance/secrets/secrets-nav-test-helper.js b/ui/tests/acceptance/secrets/secrets-nav-test-helper.js index 8667ca4f2b..1186b987ef 100644 --- a/ui/tests/acceptance/secrets/secrets-nav-test-helper.js +++ b/ui/tests/acceptance/secrets/secrets-nav-test-helper.js @@ -32,7 +32,7 @@ export default (test, type) => { await fillIn(GENERAL.inputSearch('secret-engine-path'), backend); await click(GENERAL.menuTrigger); - await click(GENERAL.menuItem('View configuration')); + await click(GENERAL.menuItem('Configure')); assert.strictEqual( currentRouteName(), `${BASE_ROUTE}.${this.expectedConfigEditRoute}`, @@ -117,7 +117,7 @@ export default (test, type) => { await visit(`/vault/secrets-engines`); await fillIn(GENERAL.inputSearch('secret-engine-path'), backend); await click(GENERAL.menuTrigger); - await click(GENERAL.menuItem('View configuration')); + await click(GENERAL.menuItem('Configure')); // For configurable engines, clicking "View configuration" will direct to its plugin settings route await waitUntil(() => currentRouteName() === `${BASE_ROUTE}.${configRoute}`); diff --git a/ui/tests/acceptance/settings-test.js b/ui/tests/acceptance/settings-test.js index e34a6a6ac0..e754176b4b 100644 --- a/ui/tests/acceptance/settings-test.js +++ b/ui/tests/acceptance/settings-test.js @@ -3,16 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { currentURL, visit, click, fillIn, currentRouteName, waitUntil } from '@ember/test-helpers'; -import { module, test } from 'qunit'; +import { click, currentRouteName, currentURL, fillIn, visit, waitUntil } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; +import { module, test } from 'qunit'; import { v4 as uuidv4 } from 'uuid'; -import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend'; module('Acceptance | secret engine mount settings', function (hooks) { setupApplicationTest(hooks); @@ -63,7 +63,7 @@ module('Acceptance | secret engine mount settings', function (hooks) { await visit('/vault/secrets-engines'); await fillIn(GENERAL.inputSearch('secret-engine-path'), path); await click(GENERAL.menuTrigger); - await click(GENERAL.menuItem('View configuration')); + await click(GENERAL.menuItem('Configure')); // since ldap hasn't been configured yet, it should redirect to configure page assert.strictEqual( currentURL(), @@ -93,7 +93,7 @@ module('Acceptance | secret engine mount settings', function (hooks) { await visit('/vault/secrets-engines'); await fillIn(GENERAL.inputSearch('secret-engine-path'), path); await click(GENERAL.menuTrigger); - await click(GENERAL.menuItem('View configuration')); + await click(GENERAL.menuItem('Configure')); assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.configuration.general-settings'); assert.strictEqual( currentURL(), @@ -125,7 +125,7 @@ module('Acceptance | secret engine mount settings', function (hooks) { await visit('/vault/secrets-engines'); await fillIn(GENERAL.inputSearch('secret-engine-path'), path); await click(GENERAL.menuTrigger); - await click(GENERAL.menuItem('View configuration')); + await click(GENERAL.menuItem('Configure')); assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.configuration.edit'); assert.strictEqual( currentURL(), diff --git a/ui/tests/integration/components/kmip/page/scopes-test.js b/ui/tests/integration/components/kmip/page/scopes-test.js index 1dc3fe05de..e16de02ea6 100644 --- a/ui/tests/integration/components/kmip/page/scopes-test.js +++ b/ui/tests/integration/components/kmip/page/scopes-test.js @@ -3,15 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'ember-qunit'; -import { setupEngine } from 'ember-engines/test-support'; -import { setupMirage } from 'ember-cli-mirage/test-support'; import { click, fillIn, render } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; +import { module, test } from 'qunit'; import sinon from 'sinon'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import SecretsEngineResource from 'vault/resources/secrets/engine'; import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Integration | Component | kmip | Page::Scopes', function (hooks) { setupRenderingTest(hooks); @@ -20,6 +21,7 @@ module('Integration | Component | kmip | Page::Scopes', function (hooks) { hooks.beforeEach(function () { this.backend = 'kmip-test'; + this.secretsEngine = new SecretsEngineResource({ path: this.backend, type: 'kmip' }); this.owner.lookup('service:secret-mount-path').update(this.backend); const { secrets } = this.owner.lookup('service:api'); @@ -51,7 +53,7 @@ module('Integration | Component | kmip | Page::Scopes', function (hooks) { this.renderComponent = () => render( - hbs``, + hbs``, { owner: this.engine } ); }); diff --git a/ui/tests/integration/components/manage-dropdown-test.js b/ui/tests/integration/components/manage-dropdown-test.js new file mode 100644 index 0000000000..48cfffe831 --- /dev/null +++ b/ui/tests/integration/components/manage-dropdown-test.js @@ -0,0 +1,161 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { click, render } from '@ember/test-helpers'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; +import SecretsEngineResource from 'vault/resources/secrets/engine'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +const DEFAULT_MOUNT_DATA = { + accessor: 'test_accessor', + config: {}, + description: '', + external_entropy_access: false, + local: false, + plugin_version: '', + running_plugin_version: '', + running_sha256: '', + seal_wrap: false, + uuid: 'test-uuid', +}; + +const TEST_CASES = [ + { + label: 'alicloud', + type: 'alicloud', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'azure', + type: 'azure', + expectedRoute: 'vault.cluster.secrets.backend.configuration.plugin-settings', + }, + { + label: 'gcp', + type: 'gcp', + expectedRoute: 'vault.cluster.secrets.backend.configuration.plugin-settings', + }, + { + label: 'gcpkms', + type: 'gcpkms', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'keymgmt', + type: 'keymgmt', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'kubernetes', + type: 'kubernetes', + expectedRoute: 'vault.cluster.secrets.backend.kubernetes.configuration', + }, + { + label: 'kvv1', + type: 'kv', + version: 1, + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'kvv2', + type: 'kv', + version: 2, + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'transform', + type: 'transform', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'transit', + type: 'transit', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { label: 'kmip', type: 'kmip', expectedRoute: 'vault.cluster.secrets.backend.kmip.configuration' }, + { label: 'ldap', type: 'ldap', expectedRoute: 'vault.cluster.secrets.backend.ldap.configuration' }, + { label: 'pki', type: 'pki', expectedRoute: 'vault.cluster.secrets.backend.pki.configuration' }, + { + label: 'ssh', + type: 'ssh', + expectedRoute: 'vault.cluster.secrets.backend.configuration.plugin-settings', + }, + { + label: 'totp', + type: 'totp', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'aws', + type: 'aws', + expectedRoute: 'vault.cluster.secrets.backend.configuration.plugin-settings', + }, + { + label: 'consul', + type: 'consul', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'nomad', + type: 'nomad', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'rabbitmq', + type: 'rabbitmq', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, + { + label: 'database', + type: 'database', + expectedRoute: 'vault.cluster.secrets.backend.configuration.general-settings', + }, +]; + +module('Integration | Component | manage-dropdown | Configure link', function (hooks) { + setupRenderingTest(hooks); + + const makeModel = ({ type, version, id }) => { + const options = version ? { version } : undefined; + return new SecretsEngineResource({ + ...DEFAULT_MOUNT_DATA, + path: `${id}/`, + type, + options, + }); + }; + + TEST_CASES.forEach(({ label, type, version, expectedRoute }) => { + test(`Configure link routes correctly for ${label}`, async function (assert) { + const routing = this.owner.lookup('service:-routing'); + const transitionSpy = sinon.stub(routing, 'transitionTo'); + const id = `${label}-integration-test`; + this.model = makeModel({ type, version, id }); + + await render( + hbs`` + ); + + await click(GENERAL.menuTrigger); + await click(GENERAL.menuItem('Configure')); + + assert.true(transitionSpy.called, `Configure action for ${label} triggers a route transition`); + assert.strictEqual( + transitionSpy.firstCall.args[0], + expectedRoute, + `Configure action for ${label} transitions to ${expectedRoute}` + ); + assert.true( + JSON.stringify(transitionSpy.firstCall.args).includes(id), + `Configure action for ${label} includes model id ${id}` + ); + + transitionSpy.restore(); + }); + }); +}); diff --git a/ui/tests/unit/components/manage-dropdown-test.js b/ui/tests/unit/components/manage-dropdown-test.js new file mode 100644 index 0000000000..50d6e0c64f --- /dev/null +++ b/ui/tests/unit/components/manage-dropdown-test.js @@ -0,0 +1,88 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; +import SecretsEngineResource from 'vault/resources/secrets/engine'; + +const makeResource = ({ type, version }) => { + const options = version ? { version } : undefined; + return new SecretsEngineResource({ + accessor: 'test_accessor', + config: {}, + description: '', + external_entropy_access: false, + local: false, + options, + path: `${type}-test/`, + plugin_version: '', + running_plugin_version: '', + running_sha256: '', + seal_wrap: false, + type, + uuid: 'test-uuid', + }); +}; + +module('Unit | Component | manage-dropdown', function (hooks) { + setupTest(hooks); + + test('backendConfigurationLink: addon engines with configRoute always use their config route', function (assert) { + const kubernetes = makeResource({ type: 'kubernetes' }); + assert.strictEqual( + kubernetes.backendConfigurationLink, + 'vault.cluster.secrets.backend.kubernetes.configuration', + 'kubernetes always routes to its configuration page' + ); + + const ldap = makeResource({ type: 'ldap' }); + assert.strictEqual( + ldap.backendConfigurationLink, + 'vault.cluster.secrets.backend.ldap.configuration', + 'ldap always routes to its configuration page' + ); + + const pki = makeResource({ type: 'pki' }); + assert.strictEqual( + pki.backendConfigurationLink, + 'vault.cluster.secrets.backend.pki.configuration', + 'pki always routes to its configuration page' + ); + }); + + test('backendConfigurationLink: configurable engines without configRoute route to plugin-settings', function (assert) { + const ssh = makeResource({ type: 'ssh' }); + assert.strictEqual( + ssh.backendConfigurationLink, + 'vault.cluster.secrets.backend.configuration.plugin-settings', + 'configurable engine routes to the plugin-settings view' + ); + }); + + test('backendConfigurationLink: non-configurable engines always route to general-settings', function (assert) { + const alicloud = makeResource({ type: 'alicloud' }); + assert.strictEqual( + alicloud.backendConfigurationLink, + 'vault.cluster.secrets.backend.configuration.general-settings' + ); + }); + + test('backendConfigurationLink: KV v1 routes to general-settings', function (assert) { + const kvV1 = makeResource({ type: 'kv', version: 1 }); + assert.strictEqual( + kvV1.backendConfigurationLink, + 'vault.cluster.secrets.backend.configuration.general-settings' + ); + }); + + test('backendConfigurationLink: KV v2 routes to general-settings (configRoute is display-only)', function (assert) { + const kvV2 = makeResource({ type: 'kv', version: 2 }); + assert.strictEqual( + kvV2.backendConfigurationLink, + 'vault.cluster.secrets.backend.configuration.general-settings', + "kv's configRoute is skipped because it's for display only" + ); + }); +}); diff --git a/ui/tests/unit/helpers/engines-display-data-test.js b/ui/tests/unit/helpers/engines-display-data-test.js index cefcd797c5..2fcd68681f 100644 --- a/ui/tests/unit/helpers/engines-display-data-test.js +++ b/ui/tests/unit/helpers/engines-display-data-test.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import engineDisplayData, { unknownEngineMetadata } from 'core/helpers/engines-display-data'; import { module, test } from 'qunit'; -import engineDisplayData, { unknownEngineMetadata } from 'vault/helpers/engines-display-data'; import { ALL_ENGINES } from 'vault/utils/all-engines-metadata'; module('Unit | Helper | engines-display-data', function () {