diff --git a/changelog/_11556.txt b/changelog/_11556.txt new file mode 100644 index 0000000000..bb7f2f3a65 --- /dev/null +++ b/changelog/_11556.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI Namespace Wizard (Enterprise)**: Onboarding wizard which provides advice to users based on their intended usage and guides them through namespace creation. +``` diff --git a/ui/app/components/page/namespaces.hbs b/ui/app/components/page/namespaces.hbs index 0725b128d9..6181b3f74a 100644 --- a/ui/app/components/page/namespaces.hbs +++ b/ui/app/components/page/namespaces.hbs @@ -4,88 +4,102 @@ }} {{#if (has-feature "Namespaces")}} - - <:breadcrumbs> - - - - - - - - - - - - Create namespace - - - - - - {{#if @model.namespaces.length}} - - - {{list.item.id}} - - - - - {{#let (concat this.namespace.path (if this.namespace.path "/") list.item.id) as |targetNamespace|}} - {{#if (includes targetNamespace this.namespace.accessibleNamespaces)}} - Switch - to namespace - {{/if}} - {{/let}} - Delete - - {{#if (eq this.nsToDelete list.item)}} - - {{/if}} - - - {{else}} - - + {{else}} + + <:breadcrumbs> + - - {{/if}} - + + + + + + + + + + + Create namespace + + + + + + {{#if @model.namespaces.length}} + + + {{list.item.id}} + + + + + {{#let (concat this.namespace.path (if this.namespace.path "/") list.item.id) as |targetNamespace|}} + {{#if (includes targetNamespace this.namespace.accessibleNamespaces)}} + Switch to namespace + {{/if}} + {{/let}} + Delete + + {{#if (eq this.nsToDelete list.item)}} + + {{/if}} + + + {{else}} + + + + {{/if}} + + {{/if}} {{else}} {{/if}} \ No newline at end of file diff --git a/ui/app/components/page/namespaces.ts b/ui/app/components/page/namespaces.ts index 7762b18a83..2ce1b7b61c 100644 --- a/ui/app/components/page/namespaces.ts +++ b/ui/app/components/page/namespaces.ts @@ -14,6 +14,7 @@ import type FlashMessageService from 'vault/services/flash-messages'; import type NamespaceService from 'vault/services/namespace'; import type RouterService from '@ember/routing/router-service'; import type { HTMLElementEvent } from 'vault/forms'; +import { DISMISSED_WIZARD_KEY } from '../wizard'; /** * @module PageNamespaces @@ -45,8 +46,6 @@ export default class PageNamespacesComponent extends Component { @service declare readonly api: ApiService; @service declare readonly router: RouterService; @service declare readonly flashMessages: FlashMessageService; - // Use namespaceService alias to avoid collision with namespaces - // input parameter from the route. @service declare namespace: NamespaceService; // The `query` property is used to track the filter @@ -54,10 +53,24 @@ export default class PageNamespacesComponent extends Component { // browser query param to prevent unnecessary re-renders. @tracked query; @tracked nsToDelete = null; + @tracked hasDismissedWizard = false; + + wizardId = 'namespace'; constructor(owner: unknown, args: Args) { super(owner, args); this.query = this.args.model.pageFilter || ''; + + // check if the wizard has already been dismissed + const dismissedWizards = localStorage.getItem(DISMISSED_WIZARD_KEY); + if (dismissedWizards?.includes(this.wizardId)) { + this.hasDismissedWizard = true; + } + } + + get showWizard() { + // Show when there are no existing namespaces and it is not in a dismissed state + return !this.hasDismissedWizard && !this.args.model.namespaces?.length; } @action diff --git a/ui/app/components/wizard/guided-setup.hbs b/ui/app/components/wizard/guided-setup.hbs new file mode 100644 index 0000000000..d2cb8c42d2 --- /dev/null +++ b/ui/app/components/wizard/guided-setup.hbs @@ -0,0 +1,56 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + +
+ + {{#each @steps as |step|}} + + <:title>{{step.title}} + + + + {{#let (component step.component) as |StepComponent|}} + + {{/let}} + + {{/each}} + + +
+ {{#if (gt @currentStep 0)}} + + {{/if}} + + + {{yield to="exit"}} + {{#if this.isFinalStep}} + {{yield to="submit"}} + {{else}} + + {{/if}} + +
+ +
\ No newline at end of file diff --git a/ui/app/components/wizard/quickstart.ts b/ui/app/components/wizard/guided-setup.ts similarity index 50% rename from ui/app/components/wizard/quickstart.ts rename to ui/app/components/wizard/guided-setup.ts index f1e58e8e6b..db513dec6b 100644 --- a/ui/app/components/wizard/quickstart.ts +++ b/ui/app/components/wizard/guided-setup.ts @@ -6,17 +6,9 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; -/** - * @module QuickStart - * QuickStart component holds the wizard content pages and navigation controls. - * - * @example - * - */ - interface Args { /** - * The active step + * The active step. Steps are zero-indexed. */ currentStep: number; /** @@ -33,30 +25,34 @@ interface Args { */ onStepChange: CallableFunction; /** - * Helper arg to conditionally render a custom submit button upon - * completion of the wizard. Necessary to avoid a nested block error. + * Whether the current step allows proceeding to the next step */ - hasSubmitBlock: boolean; + canProceed?: boolean; + /** + * State tracked across steps. + */ + wizardState?: unknown; + /** + * Callback to update state tracked across steps. + */ + updateWizardState?: CallableFunction; } -export default class QuickStart extends Component { - constructor(owner: unknown, args: Args) { - super(owner, args); - } - +export default class GuidedSetup extends Component { get isFinalStep() { return this.args.currentStep === this.args.steps.length - 1; } @action onStepChange(change: number) { - const { currentStep, steps, onStepChange } = this.args; + const { currentStep, onStepChange } = this.args; const target = currentStep + change; + onStepChange(target); + } - if (target < 0 || target > steps.length - 1) { - onStepChange(currentStep); - } else { - onStepChange(target); - } + @action + onNavStepChange(_event: Event, stepIndex: number) { + const { onStepChange } = this.args; + onStepChange(stepIndex); } } diff --git a/ui/app/components/wizard/index.hbs b/ui/app/components/wizard/index.hbs index 16420a1f85..26a893dafe 100644 --- a/ui/app/components/wizard/index.hbs +++ b/ui/app/components/wizard/index.hbs @@ -3,28 +3,53 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#if @showWelcome}} -
- {{yield to="welcome"}} +{{#if (and (has-block "welcome") this.showWelcome)}} +
+ + <:welcome> + {{yield to="welcome"}} + + + + + + +
{{else}} - - <:content> - {{yield to="quickstart"}} - - + <:exit> + {{#if (has-block "exit")}} + {{yield to="exit"}} + {{else}} + + {{/if}} + <:submit> {{#if (has-block "submit")}} {{yield to="submit"}} {{else}} - + {{/if}} - + {{/if}} \ No newline at end of file diff --git a/ui/app/components/wizard/index.ts b/ui/app/components/wizard/index.ts new file mode 100644 index 0000000000..905859a2f0 --- /dev/null +++ b/ui/app/components/wizard/index.ts @@ -0,0 +1,46 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +interface Args { + /** + * The active step. Steps are zero-indexed. + */ + currentStep: number; + /** + * Define step information to be shown in the Stepper Nav + */ + steps: { title: string; description?: string }[]; + /** + * Callback to update viewing state when the wizard is exited. + */ + onDismiss: CallableFunction; + /** + * Callback to update the current step when navigating backwards or + * forwards through the wizard + */ + onStepChange: CallableFunction; + /** + * Whether the current step allows proceeding to the next step + */ + canProceed?: boolean; + /** + * State tracked across steps. + */ + wizardState?: unknown; + /** + * Callback to update state tracked across steps. + */ + updateWizardState?: CallableFunction; +} + +// each wizard implementation can track whether the user has already dismissed the wizard via local storage +export const DISMISSED_WIZARD_KEY = 'dismissed-wizards'; + +export default class Wizard extends Component { + @tracked showWelcome = true; +} diff --git a/ui/app/components/wizard/namespaces/namespace-wizard.hbs b/ui/app/components/wizard/namespaces/namespace-wizard.hbs new file mode 100644 index 0000000000..f5c0b36287 --- /dev/null +++ b/ui/app/components/wizard/namespaces/namespace-wizard.hbs @@ -0,0 +1,39 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:welcome> + + + <:exit> + {{#unless (eq this.wizardState.creationMethod this.methods.UI)}} + + {{/unless}} + + <:submit> + {{#if (eq this.wizardState.securityPolicyChoice this.policy.FLEXIBLE)}} + + {{else if (eq this.wizardState.creationMethod this.methods.UI)}} + + {{else}} + + {{/if}} + + \ No newline at end of file diff --git a/ui/app/components/wizard/namespaces/namespace-wizard.ts b/ui/app/components/wizard/namespaces/namespace-wizard.ts new file mode 100644 index 0000000000..853da7f53a --- /dev/null +++ b/ui/app/components/wizard/namespaces/namespace-wizard.ts @@ -0,0 +1,166 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import localStorage from 'vault/lib/local-storage'; +import { SecurityPolicy } from 'vault/components/wizard/namespaces/step-1'; +import { CreationMethod } from 'vault/components/wizard/namespaces/step-3'; +import { DISMISSED_WIZARD_KEY } from 'vault/components/wizard'; + +import type ApiService from 'vault/services/api'; +import type Block from 'vault/components/wizard/namespaces/step-2'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type NamespaceService from 'vault/services/namespace'; +import type RouterService from '@ember/routing/router-service'; + +const DEFAULT_STEPS = [ + { title: 'Select setup', component: 'wizard/namespaces/step-1' }, + { title: 'Map out namespaces', component: 'wizard/namespaces/step-2' }, + { title: 'Apply changes', component: 'wizard/namespaces/step-3' }, +]; + +interface Args { + onDismiss: CallableFunction; + onRefresh: CallableFunction; +} + +interface WizardState { + securityPolicyChoice: SecurityPolicy | null; + namespacePaths: string[] | null; + namespaceBlocks: Block[] | null; + creationMethod: CreationMethod | null; + codeSnippet: string | null; +} + +export default class WizardNamespacesWizardComponent extends Component { + @service declare readonly api: ApiService; + @service declare readonly router: RouterService; + @service declare readonly flashMessages: FlashMessageService; + @service declare namespace: NamespaceService; + + @tracked steps = DEFAULT_STEPS; + @tracked wizardState: WizardState = { + securityPolicyChoice: null, + namespacePaths: null, + namespaceBlocks: null, + creationMethod: null, + codeSnippet: null, + }; + @tracked currentStep = 0; + + methods = CreationMethod; + policy = SecurityPolicy; + + wizardId = 'namespace'; + + // Whether the current step requirements have been met to proceed to the next step + get canProceed() { + switch (this.currentStep) { + case 0: // Step 1 - requires security policy choice + return Boolean(this.wizardState.securityPolicyChoice); + case 1: // Step 2 - requires valid namespace inputs + return Boolean(this.wizardState.namespacePaths); + case 2: // Step 3 - no validation is needed + return true; + default: + return true; + } + } + + get exitText() { + return this.currentStep === this.steps.length - 1 && + this.wizardState.securityPolicyChoice === SecurityPolicy.STRICT + ? 'Done & Exit' + : 'Exit'; + } + + updateSteps() { + if (this.wizardState.securityPolicyChoice === SecurityPolicy.FLEXIBLE) { + this.steps = [ + { title: 'Select setup', component: 'wizard/namespaces/step-1' }, + { title: 'Apply changes', component: 'wizard/namespaces/step-3' }, + ]; + } else { + this.steps = DEFAULT_STEPS; + } + } + + @action + onStepChange(step: number) { + this.currentStep = step; + // if user policy selection changes which steps we show, update upon page navigation + // instead of flashing the changes when toggling + this.updateSteps(); + } + + @action + updateWizardState(key: string, value: unknown) { + this.wizardState = { + ...this.wizardState, + [key]: value, + }; + } + + @action + async onSubmit() { + switch (this.wizardState.creationMethod) { + case CreationMethod.UI: + await this.createNamespacesFromWizard(); + break; + default: + // The other creation methods require the user to execute the commands on their own + // In these cases, there is no submit button + break; + } + } + + @action + async onDismiss() { + const item = localStorage.getItem(DISMISSED_WIZARD_KEY) ?? []; + localStorage.setItem(DISMISSED_WIZARD_KEY, [...item, this.wizardId]); + await this.args.onRefresh(); + this.args.onDismiss(); + } + + @action + async createNamespacesFromWizard() { + try { + const { namespacePaths } = this.wizardState; + if (!namespacePaths) return; + + for (const nsPath of namespacePaths) { + const parts = nsPath.split('/'); + const namespaceName = parts[parts.length - 1] as string; + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : undefined; + // this provides the full nested path for the header + const fullPath = parentPath ? this.namespace.path + '/' + parentPath : undefined; + await this.createNamespace(namespaceName, fullPath); + } + + this.flashMessages.success(`The namespaces have been successfully created.`); + } catch (error) { + const { message } = await this.api.parseError(error); + this.flashMessages.danger(`Error creating namespaces: ${message}`); + } finally { + await this.args.onRefresh(); + this.onDismiss(); + } + } + + @action + switchNamespace(targetNamespace: string) { + this.router.transitionTo('vault.cluster.dashboard', { + queryParams: { namespace: targetNamespace }, + }); + } + + async createNamespace(path: string, header?: string) { + const headers = header ? this.api.buildHeaders({ namespace: header }) : undefined; + await this.api.sys.systemWriteNamespacesPath(path, {}, headers); + } +} diff --git a/ui/app/components/wizard/namespaces/step-1.hbs b/ui/app/components/wizard/namespaces/step-1.hbs new file mode 100644 index 0000000000..d058ee8d7a --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-1.hbs @@ -0,0 +1,77 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + What best describes your access policy between teams and applications? + + + + + + + Flexible/shared access: + our teams are generally allowed to access secrets across different business units and applications. + + + + + + + Strict isolation required: + our policy mandates hard boundaries (separate ownership and access) between major teams, business units, or + applications. + + + + + + + {{#if @wizardState.securityPolicyChoice}} + + Your recommended setup + + {{this.cardInfo.title}} + {{this.cardInfo.description}} + + {{#if this.cardInfo.diagram}} + Namespace hierarchy example + {{/if}} + +
+ + Best for: +
+
    + {{#each this.cardInfo.bestFor as |item|}} +
  • {{item}}
  • + {{/each}} +
+ +
+ + Avoid if: +
+ +
    + {{#each this.cardInfo.avoidIf as |item|}} +
  • {{item}}
  • + {{/each}} +
+
+
+ {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/wizard/namespaces/step-1.ts b/ui/app/components/wizard/namespaces/step-1.ts new file mode 100644 index 0000000000..4cef89e963 --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-1.ts @@ -0,0 +1,52 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; + +export enum SecurityPolicy { + FLEXIBLE = 'flexible', + STRICT = 'strict', +} + +interface Args { + wizardState: { + securityPolicyChoice: SecurityPolicy | null; + }; +} + +export default class WizardNamespacesStep1 extends Component { + policy = SecurityPolicy; + + get cardInfo() { + const { wizardState } = this.args; + if (wizardState.securityPolicyChoice === SecurityPolicy.FLEXIBLE) { + return { + title: 'Single namespace', + description: + 'Your organization should be comfortable with your current setup of one global namespace. You can always add more namespaces later.', + bestFor: [ + 'Small teams or orgs just getting started with Vault.', + 'Centralized platform teams managing all secrets.', + ], + avoidIf: [ + 'You need strong isolation between teams or business units.', + 'You plan to scale to 100+ applications or secrets engines.', + 'You anticipate needing per-team Terraform workflows.', + ], + }; + } + return { + title: 'Multiple namespaces', + description: + 'Create isolation for clear ownership and scalability for strictly separated teams or applications.', + diagram: '~/multi-namespace.gif', + bestFor: [ + 'Heavily regulated organizations with strict boundary enforcement between tenants.', + 'Organizations already confident with Terraform and namespace scoping.', + ], + avoidIf: ["You're not absolutely sure you need hard isolation and nesting."], + }; + } +} diff --git a/ui/app/components/wizard/namespaces/step-2.hbs b/ui/app/components/wizard/namespaces/step-2.hbs new file mode 100644 index 0000000000..9fe66d9f93 --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-2.hbs @@ -0,0 +1,209 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + + Map out your namespaces + + + Create the namespaces you need using the 3-layer structure, starting with the global level. Refresh the preview to + update. These changes will only be applied on the next step, once you select the implementation method. + + + + + + + +
    +
  • + Logic: + We recommend using three layers, such as + + global-org-project. + + For example, treat the first column as global or company level, and the following two as departments and teams. +
  • +
  • + Naming: + Be concise and descriptive. Use meaningful words like application name, geo-region, etc. Namespaces will be + used as paths in API calls and CLI commands. You will not be able to edit it later. +
  • +
+ Example path that your users or apps will reference: + + $$global/finance-org/payroll-app/kv/data/config$$ + + +
+ + Namespace hierarchy example + +
+
+
+ {{#each this.blocks as |block blockIndex|}} + + + + + <:toggle> +
+ {{or block.global "Namespace"}} + +
+ + <:content> + {{#each block.orgs key="@index" as |org orgIndex|}} + {{#if (gt orgIndex 0)}} + + {{/if}} + + + {{! Global Column - Only show for first org }} + + {{#if (eq orgIndex 0)}} + + Global + {{#if block.globalError}} + {{block.globalError}} + {{/if}} + + {{/if}} + + + {{! Org Column }} + + + + Org + {{#if org.error}} + {{org.error}} + {{/if}} + + + {{#unless (eq orgIndex 0)}} + + {{/unless}} + + + {{! Show Add button under last input }} + {{#if (eq orgIndex (sub block.orgs.length 1))}} + + {{/if}} + + + {{! Project Column }} + + {{#each org.projects key="@index" as |project projectIndex|}} +
+ + + Project + {{#if project.error}} + {{project.error}} + {{/if}} + + + {{#unless (eq projectIndex 0)}} + + {{/unless}} + +
+ {{/each}} + + +
+
+ {{/each}} + + +
+
+
+
+ {{/each}} + + + + + + {{#if this.shouldShowTreeChart}} + + Namespace Structure Preview + + + {{/if}} + +
\ No newline at end of file diff --git a/ui/app/components/wizard/namespaces/step-2.ts b/ui/app/components/wizard/namespaces/step-2.ts new file mode 100644 index 0000000000..93a60f8037 --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-2.ts @@ -0,0 +1,339 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import type NamespaceService from 'vault/services/namespace'; + +interface Project { + name: string; + error?: string; +} + +interface Org { + name: string; + projects: Project[]; + error?: string; +} + +class Block { + @tracked global = ''; + @tracked orgs: Org[] = [{ name: '', projects: [{ name: '' }] }]; + @tracked globalError = ''; + + constructor(global = '', orgs: Org[] = [{ name: '', projects: [{ name: '' }] }]) { + this.global = global; + this.orgs = orgs; + } + + validateInput(value: string): string { + if (value.includes('/')) { + return '"/" is not allowed in namespace names'; + } else if (value.includes(' ')) { + return 'spaces are not allowed in namespace names'; + } + return ''; + } +} + +interface Args { + wizardState: { + namespacePaths: string[] | null; + namespaceBlocks: Block[] | null; + }; + updateWizardState: (key: string, value: unknown) => void; +} + +export default class WizardNamespacesStepTemp extends Component { + @service declare namespace: NamespaceService; + @tracked blocks: Block[]; + duplicateErrorMessage = 'No duplicate namespaces names are allowed within the same level'; + + constructor(owner: unknown, args: Args) { + super(owner, args); + this.blocks = args.wizardState.namespaceBlocks || [new Block()]; + } + + get treeChartOptions() { + const currentNamespace = this.namespace.currentNamespace || 'root'; + return { + height: '400px', + tree: { + type: 'tree', + rootTitle: currentNamespace, + }, + }; + } + + get hasErrors(): boolean { + return this.blocks.some((block) => { + // Check valid nesting + if (!this.isValidNesting(block)) return true; + // Check global error + if (block.globalError) return true; + // Check org errors + if (block.orgs.some((org) => org.error)) return true; + // Check project errors + return block.orgs.some((org) => org.projects.some((project) => project.error)); + }); + } + + isValidNesting(block: Block) { + // If there are non-empty orgs but no global, then it is invalid + if (block.orgs.some((org) => org.name.trim()) && !block.global.trim()) { + return false; + } + + // Check all projects have proper parents (global and org) + return block.orgs.every((org) => { + const hasProjects = org.projects.some((project) => project.name.trim()); + return !hasProjects || (block.global.trim() && org.name.trim()); + }); + } + + checkForDuplicateGlobals() { + const globals = this.blocks.map((block) => block.global.trim()).filter((global) => global !== ''); + const globalCounts = new Map(); + + globals.forEach((global) => { + globalCounts.set(global, (globalCounts.get(global) || 0) + 1); + }); + + this.blocks.forEach((block) => { + if (!block.globalError && globalCounts.get(block.global) > 1) { + block.globalError = this.duplicateErrorMessage; + } else if (globalCounts.get(block.global) === 1 && block.globalError === this.duplicateErrorMessage) { + // remove outdated error message + block.globalError = ''; + } + }); + } + + updateWizardState() { + this.args.updateWizardState('namespacePaths', this.hasErrors ? null : this.namespacePaths); + this.args.updateWizardState('namespaceBlocks', this.hasErrors ? null : this.blocks); + } + + @action + addBlock() { + this.blocks = [...this.blocks, new Block()]; + } + + @action + deleteBlock(index: number) { + if (this.blocks.length > 1) { + this.blocks = this.blocks.filter((_, i) => i !== index); + } else { + // Reset the only remaining block to initial state + this.blocks = [new Block()]; + } + // Re-validate duplicate globals in case a duplicate was deleted + this.checkForDuplicateGlobals(); + this.updateWizardState(); + } + + @action + updateGlobalValue(blockIndex: number, event: Event) { + const target = event.target as HTMLInputElement; + const block = this.blocks[blockIndex]; + if (block) { + block.global = target.value; + block.globalError = block.validateInput(target.value); + this.checkForDuplicateGlobals(); + this.updateWizardState(); + } + } + + @action + updateOrgValue(block: Block, orgToUpdate: Org, event: Event) { + const target = event.target as HTMLInputElement; + const value = target.value; + const isDuplicate = block.orgs.some((org) => org !== orgToUpdate && org.name === value); + + const updatedOrgs = block.orgs.map((org) => { + if (org === orgToUpdate) { + return { + ...org, + name: value, + error: isDuplicate ? this.duplicateErrorMessage : block.validateInput(value), + }; + } + return org; + }); + block.orgs = updatedOrgs; + + // Trigger tree reactivity by reassigning the blocks array + this.blocks = [...this.blocks]; + this.updateWizardState(); + } + + @action + addOrg(block: Block) { + block.orgs = [...block.orgs, { name: '', projects: [{ name: '' }] }]; + } + + @action + removeOrg(block: Block, orgToRemove: Org) { + if (block.orgs.length <= 1) return; + block.orgs = block.orgs.filter((org) => org !== orgToRemove); + + // Trigger tree reactivity + this.blocks = [...this.blocks]; + } + + @action + updateProjectValue(block: Block, org: Org, projectToUpdate: Project, event: Event) { + const target = event.target as HTMLInputElement; + const value = target.value; + const isDuplicate = org.projects.some((project) => project !== projectToUpdate && project.name === value); + + const updatedOrgs = block.orgs.map((currentOrg) => { + if (currentOrg === org) { + return { + ...currentOrg, + projects: currentOrg.projects.map((project) => { + if (project === projectToUpdate) { + return { + name: value, + error: isDuplicate ? this.duplicateErrorMessage : block.validateInput(value), + }; + } + return project; + }), + }; + } + return currentOrg; + }); + block.orgs = updatedOrgs; + + // Trigger tree reactivity by reassigning the blocks array + this.blocks = [...this.blocks]; + this.updateWizardState(); + } + + @action + addProject(block: Block, org: Org) { + const updatedOrgs = block.orgs.map((currentOrg) => { + if (currentOrg === org) { + return { + ...currentOrg, + projects: [...currentOrg.projects, { name: '' }], + }; + } + return currentOrg; + }); + block.orgs = updatedOrgs; + } + + @action + removeProject(block: Block, org: Org, projectToRemove: Project) { + if (org.projects.length <= 1) return; + + const updatedOrgs = block.orgs.map((currentOrg) => { + if (currentOrg === org) { + return { + ...currentOrg, + projects: currentOrg.projects.filter((project) => project !== projectToRemove), + }; + } + return currentOrg; + }); + block.orgs = updatedOrgs; + + // Trigger tree reactivity + this.blocks = [...this.blocks]; + } + + get treeData() { + const parsed = this.blocks.map((block) => { + return { + name: block.global, + children: block.orgs + .filter((org) => org.name.trim() !== '') + .map((org) => { + return { + name: org.name, + children: org.projects + .filter((project) => project.name.trim() !== '') + .map((project) => { + return { + name: project.name, + }; + }), + }; + }), + }; + }); + + return parsed; + } + + // The Carbon tree chart only supports displaying nodes with at least 1 "fork" i.e. at least 2 globals, 2 orgs or 2 projects + get shouldShowTreeChart(): boolean { + // Count total globals across blocks + const globalsCount = this.blocks.filter((block) => block.global.trim() !== '').length; + + // Check if there are multiple globals + if (globalsCount > 1) { + return true; + } + + // Check if any block has multiple orgs + const hasMultipleOrgs = this.blocks.some( + (block) => block.orgs.filter((org) => org.name.trim() !== '').length > 1 + ); + + if (hasMultipleOrgs) { + return true; + } + + // Check if any org has multiple projects + const hasMultipleProjects = this.blocks.some((block) => + block.orgs.some((org) => org.projects.filter((project) => project.name.trim() !== '').length > 1) + ); + + return hasMultipleProjects; + } + + // Store namespace paths to be used for code snippets in the format "global", "global/org", "global/org/project" + get namespacePaths(): string[] { + return this.blocks + .map((block) => { + const results: string[] = []; + + // Add global namespace if it exists + if (block.global.trim() !== '') { + results.push(block.global); + } + + block.orgs.forEach((org) => { + if (org.name.trim() !== '') { + // Add global/org namespace + const globalOrg = [block.global, org.name].filter((value) => value.trim() !== '').join('/'); + if (globalOrg && !results.includes(globalOrg)) { + results.push(globalOrg); + } + + org.projects.forEach((project) => { + if (project.name.trim() !== '') { + // Add global/org/project namespace + const fullNamespace = [block.global, org.name, project.name] + .filter((value) => value.trim() !== '') + .join('/'); + if (fullNamespace && !results.includes(fullNamespace)) { + results.push(fullNamespace); + } + } + }); + } + }); + return results; + }) + .flat() + .filter((namespace) => namespace !== ''); + } +} diff --git a/ui/app/components/wizard/namespaces/step-3.hbs b/ui/app/components/wizard/namespaces/step-3.hbs new file mode 100644 index 0000000000..f4d4f89159 --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-3.hbs @@ -0,0 +1,106 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if (eq @wizardState.securityPolicyChoice this.policy.STRICT)}} +
+ Choose your implementation + method + + {{#each this.creationMethodOptions as |option|}} + + + {{option.label}} + {{#if option.isRecommended}} + + {{/if}} + {{option.description}} + + {{/each}} + + + {{#unless (eq this.creationMethodChoice this.methods.UI)}} + Edit configuration + + {{#if (eq this.creationMethodChoice this.methods.APICLI)}} + This configuration is generated based on your input in the previous step. Copy it and run it in your terminal/use + with Vault API to apply. + {{else}} + This configuration is generated based on your input in the previous step. Copy and paste it directly to your + Terraform Vault Provider terminal. For more details on the configuration language, read the + Terraform guide. + {{/if}} + + + {{#if (eq this.creationMethodChoice this.methods.APICLI)}} + + {{#each this.tabOptions as |tabName|}} + {{tabName}} + + + + {{/each}} + + + {{else}} + + {{/if}} + + {{/unless}} +
+{{else}} +
+ + No action + needed, you're all set. +
+ + Next up: build out your access + lists and identities +

+ Make it easier for your team to adopt Vault by using ACL (Access-Control-List) and identities to maintain security + without the added complexity of namespaces. +

+ Why use ACL and identities? +
    +
  • Simplified policy writing and secret access paths.
  • +
  • No performance or scaling issues associated with namespaces.
  • +
  • Much more flexible than namespaces.
  • +
+ +
+ + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/wizard/namespaces/step-3.ts b/ui/app/components/wizard/namespaces/step-3.ts new file mode 100644 index 0000000000..4b3cdd8cd2 --- /dev/null +++ b/ui/app/components/wizard/namespaces/step-3.ts @@ -0,0 +1,121 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { SecurityPolicy } from './step-1'; +import type NamespaceService from 'vault/services/namespace'; +import { HTMLElementEvent } from 'vault/forms'; +import { + generateApiSnippet, + generateCliSnippet, + generateTerraformSnippet, +} from 'core/utils/code-generators/namespace-snippets'; + +interface Args { + wizardState: { + codeSnippet: null | string; + creationMethod: CreationMethod; + namespacePaths: string[]; + securityPolicyChoice: SecurityPolicy; + }; + updateWizardState: (key: string, value: unknown) => void; +} + +export enum CreationMethod { + TERRAFORM = 'Terraform automation', + APICLI = 'API/CLI', + UI = 'Vault UI workflow', +} + +interface CreationMethodChoice { + icon: string; + label: CreationMethod; + description: string; + isRecommended?: boolean; +} + +export default class WizardNamespacesStep3 extends Component { + @service declare readonly namespace: NamespaceService; + @tracked creationMethodChoice: CreationMethod; + @tracked selectedTab = 'API'; + + methods = CreationMethod; + policy = SecurityPolicy; + + constructor(owner: unknown, args: Args) { + super(owner, args); + this.creationMethodChoice = this.args.wizardState.creationMethod || CreationMethod.TERRAFORM; + } + + creationMethodOptions: CreationMethodChoice[] = [ + { + icon: 'terraform-color', + label: CreationMethod.TERRAFORM, + description: + 'Manage configurations by Infrastructure as Code. This creation method improves resilience and ensures common compliance requirements.', + isRecommended: true, + }, + { + icon: 'terminal-screen', + label: CreationMethod.APICLI, + description: + 'Manage namespaces directly via the Vault CLI or REST API. Best for quick updates, custom scripting, or terminal-based workflows.', + }, + { + icon: 'sidebar', + label: CreationMethod.UI, + description: + 'Apply changes immediately. Note: Changes made here may be overwritten if you also use Infrastructure as Code (Terraform).', + }, + ]; + tabOptions = ['API', 'CLI']; + + get snippet() { + const { namespacePaths } = this.args.wizardState; + switch (this.creationMethodChoice) { + case CreationMethod.TERRAFORM: + return generateTerraformSnippet(namespacePaths, this.namespace.path); + case CreationMethod.APICLI: + return this.selectedTab === 'API' + ? generateApiSnippet(namespacePaths, this.namespace.path) + : generateCliSnippet(namespacePaths, this.namespace.path); + default: + return null; + } + } + + @action + onChange(choice: CreationMethodChoice) { + this.creationMethodChoice = choice.label; + this.args.updateWizardState('creationMethod', choice.label); + // Update the code snippet whenever the creation method changes + this.updateCodeSnippet(); + } + + @action + onClickTab(_event: HTMLElementEvent, idx: number) { + this.selectedTab = this.tabOptions[idx]!; + // Update the code snippet whenever the tab changes + this.updateCodeSnippet(); + } + + // Update the wizard state with the current code snippet + @action + updateCodeSnippet() { + this.args.updateWizardState('codeSnippet', this.snippet); + } + + // Helper function to ensure valid Terraform identifiers + sanitizeId(name: string): string { + // If the name starts with a number, prefix with 'ns_' + if (/^\d/.test(name)) { + return `ns_${name}`; + } + return name; + } +} diff --git a/ui/app/components/wizard/namespaces/welcome.hbs b/ui/app/components/wizard/namespaces/welcome.hbs new file mode 100644 index 0000000000..3b2c9ac179 --- /dev/null +++ b/ui/app/components/wizard/namespaces/welcome.hbs @@ -0,0 +1,38 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + + + + Welcome to Namespaces + +
+ + +
+ Namespaces let you create secure, isolated environments where + independent teams can manage their own secrets engines, auth methods, and policies within a single Vault cluster. +
+ + Use for multi-tenancy: strict + administrative and configuration isolation + between business units and client environments. +
+
+ + Namespaces allow you to segment your cluster into + separate logical partitions, with isolated data, policy, and tokens. +
+
+ + Namespaces should not be used like folders. Use them for + large-scale, highly-regulated + environments. +
+
+
+ namespace hierarchy example + Namespaces provide necessary isolation based on your company’s organization and + access requirements. +
\ No newline at end of file diff --git a/ui/app/components/wizard/quickstart.hbs b/ui/app/components/wizard/quickstart.hbs deleted file mode 100644 index f984585b5a..0000000000 --- a/ui/app/components/wizard/quickstart.hbs +++ /dev/null @@ -1,40 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
- - -
- {{yield to="content"}} -
- -
- {{#if (gt @currentStep 0)}} - - {{/if}} - - - - - {{#if this.isFinalStep}} - {{yield to="submit"}} - {{else}} - - {{/if}} - -
- -
\ No newline at end of file diff --git a/ui/app/components/wizard/welcome.hbs b/ui/app/components/wizard/welcome.hbs new file mode 100644 index 0000000000..7f923230e3 --- /dev/null +++ b/ui/app/components/wizard/welcome.hbs @@ -0,0 +1,12 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +
+ + + {{yield to="welcome"}} + + +
\ No newline at end of file diff --git a/ui/app/styles/components/wizard.scss b/ui/app/styles/components/wizard.scss index 2cc5515465..c68642ed9d 100644 --- a/ui/app/styles/components/wizard.scss +++ b/ui/app/styles/components/wizard.scss @@ -9,26 +9,35 @@ .wizard { @extend .is-flex-column; @extend .is-flex-grow-1; - // a fixed height is needed for content to stretch properly - height: 1px; + + &.welcome { + @extend .has-top-margin-xxl; + svg { + flex-shrink: 0; + } + } .content { - // ensures button bar is always at the bottom even if content is not filled - @extend .is-flex-1; - overflow-y: auto; + padding-top: 40px; + overflow: visible; + + // Non-active panels are hidden and prevented from taking up space + &[hidden] { + display: none; + } } .button-bar { @extend .has-padding-m; @extend .is-flex-between; - background: var(--token-color-surface-primary); - box-shadow: - 0 2px 3px 0 rgba(59, 61, 69, 0.25), - 0 12px 24px 0 rgba(59, 61, 69, 0.35); - .hds-button-set { margin-left: auto; } } + + .tree { + border: 1px solid var(--token-color-border-primary); + border-radius: var(--token-border-radius-medium); + } } diff --git a/ui/app/styles/helper-classes/flexbox-and-grid.scss b/ui/app/styles/helper-classes/flexbox-and-grid.scss index e8ffb2b802..c69fc4acee 100644 --- a/ui/app/styles/helper-classes/flexbox-and-grid.scss +++ b/ui/app/styles/helper-classes/flexbox-and-grid.scss @@ -199,6 +199,9 @@ } // CHILD ELEMENT HELPERS +.align-self-flex-start { + align-self: flex-start; +} .align-self-center { align-self: center; diff --git a/ui/lib/core/addon/utils/code-generators/namespace-snippets.ts b/ui/lib/core/addon/utils/code-generators/namespace-snippets.ts new file mode 100644 index 0000000000..2aa22b0374 --- /dev/null +++ b/ui/lib/core/addon/utils/code-generators/namespace-snippets.ts @@ -0,0 +1,185 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { + sanitizeId, + terraformResourceTemplate, + terraformVariableTemplate, +} from 'core/utils/code-generators/terraform'; + +export const generateTerraformSnippet = (namespacePaths: string[], currentPath: string): string => { + const tfTopResources: string[] = []; + const tfMiddleVariables: string[] = []; + const tfMiddleResources: string[] = []; + const tfBottomVariables: string[] = []; + const tfBottomResources: string[] = []; + + // Parse to group by hierarchy + const topLevels = new Set(); + const middleLayers: { [topLayer: string]: Set } = {}; + const bottomLayers: { [middleKey: string]: Set } = {}; + + namespacePaths?.forEach((nsPath) => { + const parts = nsPath.split('/'); + const topLayer = parts[0] as string; + + if (parts.length === 1) { + topLevels.add(topLayer); + } else if (parts.length === 2) { + topLevels.add(topLayer); + if (!middleLayers[topLayer]) middleLayers[topLayer] = new Set(); + middleLayers[topLayer].add(parts[1] as string); + } else if (parts.length === 3) { + topLevels.add(topLayer); + const middleLayer = parts[1] as string; + const bottomLayer = parts[2] as string; + if (!middleLayers[topLayer]) middleLayers[topLayer] = new Set(); + middleLayers[topLayer].add(middleLayer); + const middleKey = `${topLayer}/${middleLayer}`; + if (!bottomLayers[middleKey]) bottomLayers[middleKey] = new Set(); + bottomLayers[middleKey].add(bottomLayer); + } + }); + + // Generate Terraform resources + topLevels.forEach((topLayer) => { + const sanitizedTopId = sanitizeId(topLayer); + + // Top layer resource + const topResourceArgs: { [key: string]: string } = { path: `"${topLayer}"` }; + if (currentPath) { + topResourceArgs['namespace'] = `"${currentPath}"`; + } + + tfTopResources.push( + terraformResourceTemplate({ + resource: 'vault_namespace', + localId: sanitizedTopId, + resourceArgs: topResourceArgs, + }) + ); + + // Middle layers for this top layer + const middles = middleLayers[topLayer]; + if (middles && middles.size > 0) { + const middleChildren = Array.from(middles).map((m) => `"${m}"`); + + // Middle variable and resource + tfMiddleVariables.push( + terraformVariableTemplate({ + variable: `${sanitizedTopId}_child_namespaces`, + variableArgs: { + type: 'set(string)', + default: `[${middleChildren.join(', ')}]`, + }, + }) + ); + + const namespaceReference = currentPath + ? `vault_namespace.${sanitizedTopId}.path_fq` + : `vault_namespace.${sanitizedTopId}.path`; + + tfMiddleResources.push( + terraformResourceTemplate({ + resource: 'vault_namespace', + localId: `${sanitizedTopId}_children`, + resourceArgs: { + for_each: `var.${sanitizedTopId}_child_namespaces`, + namespace: namespaceReference, + path: 'each.key', + }, + }) + ); + + // Bottom layers for each middle layer + middles.forEach((middleLayer) => { + const middleKey = `${topLayer}/${middleLayer}`; + const bottoms = bottomLayers[middleKey]; + + if (bottoms && bottoms.size > 0) { + const sanitizedMiddleId = sanitizeId(middleLayer); + const bottomChildren = Array.from(bottoms).map((b) => `"${b}"`); + + // Bottom variable and resource + tfBottomVariables.push( + terraformVariableTemplate({ + variable: `${sanitizedTopId}_${sanitizedMiddleId}_child_namespaces`, + variableArgs: { + type: 'set(string)', + default: `[${bottomChildren.join(', ')}]`, + }, + }) + ); + + tfBottomResources.push( + terraformResourceTemplate({ + resource: 'vault_namespace', + localId: `${sanitizedTopId}_${sanitizedMiddleId}_children`, + resourceArgs: { + for_each: `var.${sanitizedTopId}_${sanitizedMiddleId}_child_namespaces`, + namespace: `vault_namespace.${sanitizedTopId}_children["${middleLayer}"].path_fq`, + path: 'each.key', + }, + }) + ); + } + }); + } + }); + + // Build in proper dependency order + const orderedSections = [ + tfMiddleVariables.join('\n\n'), + tfBottomVariables.join('\n\n'), + tfTopResources.join('\n\n'), + tfMiddleResources.join('\n\n'), + tfBottomResources.join('\n\n'), + ].filter((section) => section.trim() !== ''); + + return orderedSections.join('\n\n'); +}; + +export const generateCliSnippet = (namespacePaths: string[], currentPath: string): string => { + const cliSnippet: string[] = []; + + namespacePaths.forEach((nsPath) => { + const parts = nsPath.split('/'); + + if (parts.length === 1) { + // Top level namespace + const fullPath = currentPath ? `-namespace ${currentPath} ` : ''; + cliSnippet.push(`vault namespace create ${fullPath}${nsPath}/`); + } else if (parts.length === 2) { + // Middle level namespace + const parentNs = parts[0]; + const fullPath = currentPath ? `${currentPath}/${parentNs}` : parentNs; + cliSnippet.push(`vault namespace create -namespace ${fullPath} ${parts[1]}/`); + } else if (parts.length === 3) { + // Bottom level namespace + const parentNs = parts[0] + '/' + parts[1]; + const fullPath = currentPath ? `${currentPath}/${parentNs}` : parentNs; + cliSnippet.push(`vault namespace create -namespace ${fullPath} ${parts[2]}/`); + } + }); + + return cliSnippet.join('\n'); +}; + +export const generateApiSnippet = (namespacePaths: string[], currentPath: string): string => { + const apiSnippet: string[] = namespacePaths.map((nsPath) => { + const parts = nsPath.split('/'); + + if (parts.length === 1) { + const nsHeader = currentPath ? ` --header "X-Vault-Namespace: ${currentPath}"\\\n` : ''; + return `curl \\\n --header "X-Vault-Token: $VAULT_TOKEN" \\\n${nsHeader} --request PUT \\\n $VAULT_ADDR/v1/sys/namespaces/${nsPath}`; + } else { + const parentPath = currentPath + '/' + parts.slice(0, -1).join('/'); + const childName = parts[parts.length - 1]; + return `curl \\\n --header "X-Vault-Token: $VAULT_TOKEN" \\\n --header "X-Vault-Namespace: ${parentPath}" \\\n --request PUT \\\n $VAULT_ADDR/v1/sys/namespaces/${childName}`; + } + }); + + return apiSnippet.join('\n\n'); +}; diff --git a/ui/lib/core/addon/utils/code-generators/terraform.ts b/ui/lib/core/addon/utils/code-generators/terraform.ts index 180634259b..9caca8680f 100644 --- a/ui/lib/core/addon/utils/code-generators/terraform.ts +++ b/ui/lib/core/addon/utils/code-generators/terraform.ts @@ -103,3 +103,13 @@ ${formatted.join('\n')} }; const formatKvPairs = (indent: string, key: string, value: unknown) => `${indent}${key} = ${value}`; + +// Helper function to ensure valid Terraform identifiers +// https://developer.hashicorp.com/terraform/language/syntax/configuration#identifiers +export const sanitizeId = (name: string): string => { + // If the name starts with a number, prefix with 'ns_' + if (/^\d/.test(name)) { + return `ns_${name}`; + } + return name; +}; diff --git a/ui/public/images/multi-namespace.gif b/ui/public/images/multi-namespace.gif new file mode 100644 index 0000000000..94e71e00f8 Binary files /dev/null and b/ui/public/images/multi-namespace.gif differ diff --git a/ui/public/images/namespaces-welcome.png b/ui/public/images/namespaces-welcome.png new file mode 100644 index 0000000000..63072868d0 Binary files /dev/null and b/ui/public/images/namespaces-welcome.png differ diff --git a/ui/tests/acceptance/access/namespaces/index-test.js b/ui/tests/acceptance/access/namespaces/index-test.js index 861142d5d6..f4b2230c42 100644 --- a/ui/tests/acceptance/access/namespaces/index-test.js +++ b/ui/tests/acceptance/access/namespaces/index-test.js @@ -9,13 +9,15 @@ import { setupApplicationTest } from 'ember-qunit'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { createNS, deleteNS, runCmd } from 'vault/tests/helpers/commands'; +import localStorage from 'vault/lib/local-storage'; module('Acceptance | Enterprise | /access/namespaces', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(async () => { await login(); - + // dismiss the wizard + localStorage.setItem('dismissed-wizards', ['namespace']); // Go to the manage namespaces page await visit('/vault/access/namespaces'); }); diff --git a/ui/tests/acceptance/enterprise-namespaces-test.js b/ui/tests/acceptance/enterprise-namespaces-test.js index b394e98ebf..7c45a46b66 100644 --- a/ui/tests/acceptance/enterprise-namespaces-test.js +++ b/ui/tests/acceptance/enterprise-namespaces-test.js @@ -20,12 +20,15 @@ import { runCmd, createNSFromPaths, deleteNSFromPaths } from 'vault/tests/helper import { login, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import localStorage from 'vault/lib/local-storage'; module('Acceptance | Enterprise | namespaces', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(async () => { await login(); + // dismiss wizard + localStorage.setItem('dismissed-wizards', ['namespace']); }); test('it focuses the search input field when user toggles namespace picker', async function (assert) { diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index 1c99a72ee1..86db7ca9a0 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -36,7 +36,6 @@ export const GENERAL = { confirmButton: '[data-test-confirm-button]', // used most often on modal or confirm popups confirmTrigger: '[data-test-confirm-action-trigger]', copyButton: '[data-test-copy-button]', - nextButton: '[data-test-next-button]', revealButton: (label: string) => `[data-test-reveal="${label}"] button`, // intended for Hds::Reveal components accordionButton: (label: string) => `[data-test-accordion="${label}"] button`, // intended for Hds::Accordion components // there should only be one submit button per view (e.g. one per form) so this does not need to be dynamic @@ -89,6 +88,7 @@ export const GENERAL = { labelByGroupControlIndex: (index: number) => `.hds-form-group__control-field:nth-of-type(${index}) label`, maskedInput: '[data-test-masked-input]', radioByAttr: (attr: string) => (attr ? `[data-test-radio="${attr}"]` : '[data-test-radio]'), + radioCardByAttr: (attr: string) => (attr ? `[data-test-radio-card="${attr}"]` : '[data-test-radio-card]'), selectByAttr: (attr: string) => `[data-test-select="${attr}"]`, stringListByIdx: (index: number) => `[data-test-string-list-input="${index}"]`, textToggle: '[data-test-text-toggle]', diff --git a/ui/tests/integration/components/page/namespaces-wizard-test.js b/ui/tests/integration/components/page/namespaces-wizard-test.js new file mode 100644 index 0000000000..23fbd1dead --- /dev/null +++ b/ui/tests/integration/components/page/namespaces-wizard-test.js @@ -0,0 +1,145 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, fillIn, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import sinon from 'sinon'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +const SELECTORS = { + content: '[data-test-content]', + guidedSetup: '[data-test-guided-setup]', + stepTitle: '[data-test-step-title]', + welcome: '[data-test-welcome]', + inputRow: (index) => (index ? `[data-test-input-row="${index}"]` : '[data-test-input-row]'), +}; + +module('Integration | Component | page/namespaces | Namespace Wizard', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.onFilterChange = sinon.spy(); + this.onDismiss = sinon.spy(); + this.onRefresh = sinon.spy(); + + this.renderComponent = () => { + return render(hbs` + + `); + }; + }); + + hooks.afterEach(async function () { + // ensure clean state + localStorage.clear(); + }); + + test('it shows wizard when no namespaces exist', async function (assert) { + await this.renderComponent(); + assert.dom(SELECTORS.welcome).exists('Wizard welcome is rendered'); + }); + + test('it progresses through wizard steps with strict policy', async function (assert) { + await this.renderComponent(); + await click(GENERAL.button('Guided setup')); + + // Step 1: Choose security policy + assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no policy choice'); + await click(GENERAL.radioByAttr('strict')); + await click(GENERAL.button('Next')); + + // Step 2: Add namespace data + assert.dom(SELECTORS.stepTitle).hasText('Map out your namespaces'); + await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global'); + + await click(GENERAL.button('Next')); + + // Step 3: Choose implementation method + assert.dom(SELECTORS.stepTitle).hasText('Choose your implementation method'); + assert.dom(GENERAL.copyButton).exists(); + }); + + test('it skips step 2 with flexible policy', async function (assert) { + await this.renderComponent(); + await click(GENERAL.button('Guided setup')); + + // Step 1: Choose flexible policy + await click(GENERAL.radioByAttr('flexible')); + await click(GENERAL.button('Next')); + + // Should skip directly to step 3 + assert.dom(SELECTORS.stepTitle).hasText(`No action needed, you're all set.`); + assert.dom(GENERAL.button('identities')).exists(); + }); + + test('it shows different code snippets per creation method option', async function (assert) { + await this.renderComponent(); + await click(GENERAL.button('Guided setup')); + await click(GENERAL.radioByAttr('strict')); + await click(GENERAL.button('Next')); + + await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global'); + await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-0')}`, 'org1'); + await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-0')}`, 'proj1'); + await click(GENERAL.button('Next')); + + // Assert code snippet changes + assert.dom(GENERAL.radioCardByAttr('Terraform automation')).exists('Terraform option exists'); + assert + .dom(GENERAL.fieldByAttr('snippets')) + .hasTextContaining(`variable "global_child_namespaces"`, 'shows terraform code snippet by default'); + + await click(GENERAL.radioCardByAttr('API/CLI')); + assert + .dom(GENERAL.fieldByAttr('snippets')) + .hasTextContaining(`curl`, 'shows API code snippet by default for API/CLI radio card'); + + await click(GENERAL.hdsTab('CLI')); + assert + .dom(GENERAL.fieldByAttr('snippets')) + .hasTextContaining(`vault namespace create`, 'shows CLI code snippet by for CLI tab'); + + await click(GENERAL.radioCardByAttr('Vault UI workflow')); + assert.dom(GENERAL.fieldByAttr('snippets')).doesNotExist('does not render a code snippet for UI flow'); + }); + + test('it allows adding and removing blocks, org, and project inputs', async function (assert) { + await this.renderComponent(); + await click(GENERAL.button('Guided setup')); + await click(GENERAL.radioByAttr('strict')); + await click(GENERAL.button('Next')); + + // Add a second block + await click(GENERAL.button('add namespace')); + assert.dom(`${SELECTORS.inputRow(1)}`).exists('Second input block exists'); + await click(`${SELECTORS.inputRow(1)} ${GENERAL.button('delete namespace')}`); + assert.dom(`${SELECTORS.inputRow(1)}`).doesNotExist('Second input block was removed'); + + // Test adding and removing project input + await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add project')}`); + assert + .dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`) + .exists('project input was added'); + await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete project')}`); + assert + .dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`) + .doesNotExist('project input was removed'); + + // Test adding and removing org input + await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add org')}`); + assert.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`).exists('org input was added'); + await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete org')}`); + assert + .dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`) + .doesNotExist('org input was removed'); + }); +}); diff --git a/ui/tests/integration/components/wizard-test.js b/ui/tests/integration/components/wizard-test.js index ae1e453d47..da1e993c0a 100644 --- a/ui/tests/integration/components/wizard-test.js +++ b/ui/tests/integration/components/wizard-test.js @@ -11,8 +11,8 @@ import hbs from 'htmlbars-inline-precompile'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; const SELECTORS = { - welcome: '[data-test-welcome-content]', - quickstart: '[data-test-quickstart-content]', + welcome: '[data-test-welcome]', + guidedSetup: '[data-test-guided-setup]', }; module('Integration | Component | Wizard', function (hooks) { @@ -25,64 +25,54 @@ module('Integration | Component | Wizard', function (hooks) { { title: 'Almost done' }, { title: 'Finale' }, ]; - this.step = 0; - this.showWelcome = false; + this.currentStep = 0; + this.canProceed = true; + this.welcomeDocLink = 'test'; this.onDismiss = sinon.spy(); this.onStepChange = sinon.spy(); }); test('it shows welcome content initially, then hides it when entering wizard', async function (assert) { - this.set('showWelcome', true); await render(hbs` <:welcome>
Some welcome content
- {{!-- TODO: This will change once the welcome page structure is defined in a follow up PR --}} - - - <:quickstart> -
Quickstart content
-
`); - // Assert welcome content is rendered and quickstart content is not + // Assert welcome content is rendered and guided setup content is not assert.dom(SELECTORS.welcome).exists('Welcome content is rendered initially'); assert.dom(SELECTORS.welcome).hasTextContaining('Some welcome content'); assert - .dom(SELECTORS.quickstart) - .doesNotExist('Quickstart content is not rendered when welcome is displayed'); + .dom(SELECTORS.guidedSetup) + .doesNotExist('guidedSetup content is not rendered when welcome is displayed'); - await click('[data-test-enter-wizard-button]'); + await click(GENERAL.button('Guided setup')); - // Assert welcome content is no longer rendered and that quickstart content is rendered + // Assert welcome content is no longer rendered and that guided setup content is rendered assert.dom(SELECTORS.welcome).doesNotExist('Welcome content is hidden after entering wizard'); - assert.dom(SELECTORS.quickstart).exists('Quickstart content is now rendered'); - assert.dom(SELECTORS.quickstart).hasTextContaining('Quickstart content'); + assert.dom(SELECTORS.guidedSetup).exists('guidedSetup content is now rendered'); + assert.dom(SELECTORS.guidedSetup).hasTextContaining('First step'); }); test('it shows custom submit block when provided', async function (assert) { // Go to final step - this.set('step', 3); + this.currentStep = 3; this.onCustomSubmit = sinon.spy(); await render(hbs` - <:quickstart> -
Quickstart content
- <:submit> @@ -96,12 +86,12 @@ module('Integration | Component | Wizard', function (hooks) { test('it shows default submit button when custom submit block is not provided', async function (assert) { // Go to final step - this.set('step', 3); + this.currentStep = 3; await render(hbs` - <:quickstart> -
Quickstart content
-
`); - assert.dom(GENERAL.nextButton).exists('Next button is rendered when not on final step'); + assert.dom(GENERAL.button('Next')).exists('Next button is rendered when not on final step'); assert.dom(GENERAL.submitButton).doesNotExist('Submit button is not rendered when not on final step'); - await click(GENERAL.nextButton); + await click(GENERAL.button('Next')); assert.true(this.onStepChange.calledOnce, 'onStepChange is called'); // Go to final step - this.set('step', 3); - assert.dom(GENERAL.nextButton).doesNotExist('Next button is not rendered when on the final step'); + this.set('currentStep', 3); + assert.dom(GENERAL.button('next')).doesNotExist('Next button is not rendered when on the final step'); assert.dom(GENERAL.submitButton).exists('Submit button is rendered on final step'); }); @@ -144,38 +131,32 @@ module('Integration | Component | Wizard', function (hooks) { await render(hbs` - <:quickstart> -
Quickstart content
-
`); assert.dom(GENERAL.backButton).doesNotExist('Back button is not rendered on the first step'); - this.set('step', 2); + this.set('currentStep', 2); assert.dom(GENERAL.backButton).exists('Back button is shown when not on first step'); await click(GENERAL.backButton); assert.true(this.onStepChange.calledOnce, 'onStepChange is called'); }); - test('it dismisses wizard when exit button is clicked within quickstart', async function (assert) { + test('it dismisses wizard when exit button is clicked within guided setup', async function (assert) { await render(hbs` - <:quickstart> -
Quickstart content
-
`); - assert.dom(GENERAL.cancelButton).exists('Exit button is shown within quickstart'); + assert.dom(GENERAL.cancelButton).exists('Exit button is shown within guided setup'); await click(GENERAL.cancelButton); assert.true(this.onDismiss.calledOnce, 'onDismiss is called when exit button is clicked'); });