UI: Namespace Wizard (#11556) (#12053)

* fill guided start content

* move namespace logic into page component

* add page component tests for namespace wizard

* add tree chart and changelog, update state management

* fix failing page usage test

* add back in breadcrumb update lost in merge conflict resolution across files

* fix test

* update terraform template function usage

* Update ui/app/components/wizard/namespaces/step-3.hbs



* formatting and fixes

* revert usage page changes

* move snippet generators into util and update code snippet initialization

* update test namespace page args

* move namespace wizard logic into its own component

* fix nested namespace creation via api and cli code snippets

* test update

* nested namespace terraform snippet

* remove outdated comment

* test clean up and hide wizard in CE

---------

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-01-28 14:07:13 -05:00 committed by GitHub
parent e3cdfec694
commit c499aa5288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1827 additions and 217 deletions

3
changelog/_11556.txt Normal file
View file

@ -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.
```

View file

@ -4,88 +4,102 @@
}}
{{#if (has-feature "Namespaces")}}
<Page::Header @title="Namespaces">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label="Namespaces")}}
/>
</:breadcrumbs>
</Page::Header>
<Toolbar>
<ToolbarFilters>
<FilterInputExplicit
@query={{@model.pageFilter}}
@placeholder="Search"
@handleSearch={{this.handleSearch}}
@handleInput={{this.handleInput}}
@handleKeyDown={{this.handleKeyDown}}
/>
</ToolbarFilters>
<ToolbarActions>
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="reload"
@iconPosition="trailing"
@text="Refresh list"
{{on "click" this.refreshNamespaceList}}
data-test-button="refresh-namespace-list"
/>
<ToolbarLink @route="vault.cluster.access.namespaces.create" @type="add" data-test-link-to="create-namespace">
Create namespace
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<ListView
@items={{@model.namespaces}}
@itemNoun="namespace"
@paginationRouteName="vault.cluster.access.namespaces"
@onPageChange={{this.handlePageChange}}
as |list|
>
{{#if @model.namespaces.length}}
<ListItem as |Item|>
<Item.content>
{{list.item.id}}
</Item.content>
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon @icon="more-horizontal" @text="More options" @hasChevron={{false}} data-test-popup-menu-trigger />
{{#let (concat this.namespace.path (if this.namespace.path "/") list.item.id) as |targetNamespace|}}
{{#if (includes targetNamespace this.namespace.accessibleNamespaces)}}
<dd.Interactive {{on "click" (fn this.switchNamespace targetNamespace)}} data-test-popup-menu="switch">Switch
to namespace</dd.Interactive>
{{/if}}
{{/let}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.nsToDelete) list.item)}}
data-test-popup-menu="delete"
>Delete</dd.Interactive>
</Hds::Dropdown>
{{#if (eq this.nsToDelete list.item)}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.nsToDelete) null}}
@onConfirm={{fn this.deleteNamespace list.item}}
@confirmTitle="Delete this namespace?"
@confirmMessage="Any engines or mounts in this namespace will also be removed."
/>
{{/if}}
</Item.menu>
</ListItem>
{{else}}
<list.empty>
<Hds::Link::Standalone
@icon="learn-link"
@text="Secure multi-tenancy with namespaces tutorial"
@href={{doc-link "/vault/tutorials/enterprise/namespaces"}}
{{#if this.showWizard}}
<Wizard::Namespaces::NamespaceWizard
@onDismiss={{fn (mut this.hasDismissedWizard) true}}
@onRefresh={{this.refreshNamespaceList}}
/>
{{else}}
<Page::Header @title="Namespaces">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label="Namespaces")}}
/>
</list.empty>
{{/if}}
</ListView>
</:breadcrumbs>
</Page::Header>
<Toolbar>
<ToolbarFilters>
<FilterInputExplicit
@query={{@model.pageFilter}}
@placeholder="Search"
@handleSearch={{this.handleSearch}}
@handleInput={{this.handleInput}}
@handleKeyDown={{this.handleKeyDown}}
/>
</ToolbarFilters>
<ToolbarActions>
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="reload"
@iconPosition="trailing"
@text="Refresh list"
{{on "click" this.refreshNamespaceList}}
data-test-button="refresh-namespace-list"
/>
<ToolbarLink @route="vault.cluster.access.namespaces.create" @type="add" data-test-link-to="create-namespace">
Create namespace
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<ListView
@items={{@model.namespaces}}
@itemNoun="namespace"
@paginationRouteName="vault.cluster.access.namespaces"
@onPageChange={{this.handlePageChange}}
as |list|
>
{{#if @model.namespaces.length}}
<ListItem as |Item|>
<Item.content>
{{list.item.id}}
</Item.content>
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="More options"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#let (concat this.namespace.path (if this.namespace.path "/") list.item.id) as |targetNamespace|}}
{{#if (includes targetNamespace this.namespace.accessibleNamespaces)}}
<dd.Interactive
{{on "click" (fn this.switchNamespace targetNamespace)}}
data-test-popup-menu="switch"
>Switch to namespace</dd.Interactive>
{{/if}}
{{/let}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.nsToDelete) list.item)}}
data-test-popup-menu="delete"
>Delete</dd.Interactive>
</Hds::Dropdown>
{{#if (eq this.nsToDelete list.item)}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.nsToDelete) null}}
@onConfirm={{fn this.deleteNamespace list.item}}
@confirmTitle="Delete this namespace?"
@confirmMessage="Any engines or mounts in this namespace will also be removed."
/>
{{/if}}
</Item.menu>
</ListItem>
{{else}}
<list.empty>
<Hds::Link::Standalone
@icon="learn-link"
@text="Secure multi-tenancy with namespaces tutorial"
@href={{doc-link "/vault/tutorials/enterprise/namespaces"}}
/>
</list.empty>
{{/if}}
</ListView>
{{/if}}
{{else}}
<UpgradePage @title="Namespaces" @minimumEdition="Vault Enterprise Pro" />
{{/if}}

View file

@ -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<Args> {
@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<Args> {
// 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

View file

@ -0,0 +1,56 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Namespaces Guided Start" />
<div class="wizard" data-test-guided-setup>
<Hds::Stepper::Nav
class="has-top-margin-xl has-bottom-margin-xl is-flex-column is-flex-grow-1"
@isInteractive={{true}}
@onStepChange={{this.onNavStepChange}}
@currentStep={{@currentStep}}
@steps={{@steps}}
as |S|
>
{{#each @steps as |step|}}
<S.Step>
<:title>{{step.title}}</:title>
</S.Step>
<S.Panel class="content" data-test-content>
{{#let (component step.component) as |StepComponent|}}
<StepComponent @wizardState={{@wizardState}} @updateWizardState={{@updateWizardState}} />
{{/let}}
</S.Panel>
{{/each}}
</Hds::Stepper::Nav>
<div class="button-bar">
{{#if (gt @currentStep 0)}}
<Hds::Button
@text="Back"
@color="tertiary"
@icon="chevron-left"
{{on "click" (fn this.onStepChange -1)}}
data-test-back-button
/>
{{/if}}
<Hds::ButtonSet>
{{yield to="exit"}}
{{#if this.isFinalStep}}
{{yield to="submit"}}
{{else}}
<Hds::Button
@text="Next"
disabled={{not @canProceed}}
{{on "click" (fn this.onStepChange 1)}}
data-test-button="Next"
/>
{{/if}}
</Hds::ButtonSet>
</div>
</div>

View file

@ -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
* <QuickStart @currentStep={{@currentStep}} @steps={{@steps}} @onStepChange={{@onStepChange}} @onDismiss={{@onDismiss}} @hasSubmitBlock={{has-block "submit"}} />
*/
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<Args> {
constructor(owner: unknown, args: Args) {
super(owner, args);
}
export default class GuidedSetup extends Component<Args> {
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);
}
}

View file

@ -3,28 +3,53 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @showWelcome}}
<div data-test-welcome-content>
{{yield to="welcome"}}
{{#if (and (has-block "welcome") this.showWelcome)}}
<div data-test-welcome>
<Wizard::Welcome>
<:welcome>
{{yield to="welcome"}}
<Hds::ButtonSet>
<Hds::Button
@icon="rocket"
@text="Guided setup"
{{on "click" (fn (mut this.showWelcome) false)}}
data-test-button="Guided setup"
/>
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} />
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="View documentation"
@href={{@welcomeDocLink}}
class="has-left-margin-m"
/>
</Hds::ButtonSet>
</:welcome>
</Wizard::Welcome>
</div>
{{else}}
<Wizard::Quickstart
<Wizard::GuidedSetup
@currentStep={{@currentStep}}
@steps={{@steps}}
@onStepChange={{@onStepChange}}
@onDismiss={{@onDismiss}}
@hasSubmitBlock={{has-block "submit"}}
@canProceed={{@canProceed}}
@wizardState={{@wizardState}}
@updateWizardState={{@updateWizardState}}
>
<:content>
{{yield to="quickstart"}}
</:content>
<:exit>
{{#if (has-block "exit")}}
{{yield to="exit"}}
{{else}}
<Hds::Button @text="Exit" {{on "click" @onDismiss}} data-test-cancel />
{{/if}}
</:exit>
<:submit>
{{#if (has-block "submit")}}
{{yield to="submit"}}
{{else}}
<Hds::Button @text="Done" {{on "click" @onDismiss}} data-test-submit />
<Hds::Button @text="Mark as complete" {{on "click" @onDismiss}} data-test-submit />
{{/if}}
</:submit>
</Wizard::Quickstart>
</Wizard::GuidedSetup>
{{/if}}

View file

@ -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<Args> {
@tracked showWelcome = true;
}

View file

@ -0,0 +1,39 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Wizard
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
@canProceed={{this.canProceed}}
@welcomeDocLink={{doc-link "/vault/docs/enterprise/namespaces"}}
@wizardState={{this.wizardState}}
@updateWizardState={{this.updateWizardState}}
>
<:welcome>
<Wizard::Namespaces::Welcome />
</:welcome>
<:exit>
{{#unless (eq this.wizardState.creationMethod this.methods.UI)}}
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} />
{{/unless}}
</:exit>
<:submit>
{{#if (eq this.wizardState.securityPolicyChoice this.policy.FLEXIBLE)}}
<Hds::Button @text="Done" {{on "click" this.onDismiss}} data-test-button="done" />
{{else if (eq this.wizardState.creationMethod this.methods.UI)}}
<Hds::Button @text="Apply" {{on "click" this.onSubmit}} data-test-button="apply" />
{{else}}
<Hds::Copy::Button
@text="Copy code"
@textToCopy={{this.wizardState.codeSnippet}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
class="primary"
data-test-copy-button
/>
{{/if}}
</:submit>
</Wizard>

View file

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

View file

@ -0,0 +1,77 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Grid @columnWidth="50%" @gap="16" as |LG|>
<LG.Item>
<Hds::Text::Display @tag="h2" @size="400" @weight="bold" class="has-bottom-padding-m">
What best describes your access policy between teams and applications?
</Hds::Text::Display>
<Hds::Form::Radio::Group @name="security-policy" as |G|>
<G.RadioField
checked={{eq @wizardState.securityPolicyChoice this.policy.FLEXIBLE}}
{{on "change" (fn @updateWizardState "securityPolicyChoice" this.policy.FLEXIBLE)}}
data-test-radio="flexible"
as |F|
>
<F.Label>
<Hds::Text::Body @tag="p">
<strong>Flexible/shared access:</strong>
our teams are generally allowed to access secrets across different business units and applications.
</Hds::Text::Body>
</F.Label>
</G.RadioField>
<G.RadioField
checked={{eq @wizardState.securityPolicyChoice this.policy.STRICT}}
{{on "change" (fn @updateWizardState "securityPolicyChoice" this.policy.STRICT)}}
data-test-radio="strict"
as |F|
>
<F.Label>
<Hds::Text::Body @tag="p">
<strong>Strict isolation required:</strong>
our policy mandates hard boundaries (separate ownership and access) between major teams, business units, or
applications.
</Hds::Text::Body>
</F.Label>
</G.RadioField>
</Hds::Form::Radio::Group>
</LG.Item>
{{#if @wizardState.securityPolicyChoice}}
<LG.Item>
<Hds::Text::Display @tag="h2" @size="400" @weight="bold" class="has-bottom-padding-m">Your recommended setup</Hds::Text::Display>
<Hds::Card::Container @background="neutral-secondary" @hasBorder={{true}} class="has-padding-m">
<Hds::Text::Display @tag="h3" @size="200" class="has-bottom-padding-m">{{this.cardInfo.title}}</Hds::Text::Display>
<Hds::Text::Body @tag="p">{{this.cardInfo.description}}</Hds::Text::Body>
{{#if this.cardInfo.diagram}}
<img src={{img-path this.cardInfo.diagram}} alt="Namespace hierarchy example" />
{{/if}}
<div class="is-flex-center has-top-padding-m">
<Hds::Icon @name="check" @color="success" />
<Hds::Text::Body class="has-left-margin-xs"><strong>Best for:</strong></Hds::Text::Body>
</div>
<ul class="bullet">
{{#each this.cardInfo.bestFor as |item|}}
<li>{{item}}</li>
{{/each}}
</ul>
<div class="is-flex-center has-top-padding-m">
<Hds::Icon @name="x" @color="critical" />
<Hds::Text::Body class="has-left-margin-xs"><strong>Avoid if:</strong></Hds::Text::Body>
</div>
<ul class="bullet">
{{#each this.cardInfo.avoidIf as |item|}}
<li>{{item}}</li>
{{/each}}
</ul>
</Hds::Card::Container>
</LG.Item>
{{/if}}
</Hds::Layout::Grid>

View file

@ -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<Args> {
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."],
};
}
}

View file

@ -0,0 +1,209 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Grid @gap="16" @columnMinWidth="25%" as |LG|>
<LG.Item @colspan={{4}}>
<Hds::Text::Display @tag="h2" @size="400" class="has-bottom-padding-m" data-test-step-title>Map out your namespaces</Hds::Text::Display>
<Hds::Text::Body @tag="p">
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.
</Hds::Text::Body>
</LG.Item>
<LG.Item @colspan={{4}}>
<Hds::Reveal @isOpen={{true}} @text="Namespace best practices">
<Hds::Layout::Grid @columnMinWidth="10%" @gap="24" as |LG|>
<LG.Item @colspan={{7}}>
<ul class="bullet">
<li>
<strong>Logic:</strong>
We recommend using three layers, such as
<Hds::Text::Code @weight="bold">
global-org-project.
</Hds::Text::Code>
For example, treat the first column as global or company level, and the following two as departments and teams.
</li>
<li>
<strong>Naming:</strong>
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.
</li>
</ul>
<Hds::Text:Body @tag="p" class="has-top-margin-s">Example path that your users or apps will reference:</Hds::Text:Body>
<Hds::Text::Code @tag="p" class="has-top-margin-s">
$$global/finance-org/payroll-app/kv/data/config$$
</Hds::Text::Code>
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="Learn more about namespaces"
@href="https://developer.hashicorp.com/vault/docs/enterprise/namespaces/namespace-structure"
class="has-top-margin-s"
/>
</LG.Item>
<LG.Item @colspan={{3}}>
<img src={{img-path "~/multi-namespace.gif"}} alt="Namespace hierarchy example" />
</LG.Item>
</Hds::Layout::Grid>
</Hds::Reveal>
</LG.Item>
{{#each this.blocks as |block blockIndex|}}
<LG.Item @colspan={{4}}>
<Hds::Card::Container @hasBorder={{true}}>
<Hds::Accordion as |A|>
<A.Item @containsInteractive={{true}} @isOpen={{true}} data-test-input-row={{blockIndex}}>
<:toggle>
<div class="flex space-between align-items-center">
<span>{{or block.global "Namespace"}}</span>
<Hds::Button
@text="Delete namespace"
@color="secondary"
@icon="trash"
@isIconOnly={{true}}
{{on "click" (fn this.deleteBlock blockIndex)}}
data-test-button="delete namespace"
/>
</div>
</:toggle>
<:content>
{{#each block.orgs key="@index" as |org orgIndex|}}
{{#if (gt orgIndex 0)}}
<Hds::Separator />
{{/if}}
<Hds::Layout::Grid @gap="16" as |Grid|>
{{! Global Column - Only show for first org }}
<Grid.Item>
{{#if (eq orgIndex 0)}}
<Hds::Form::TextInput::Field
@value={{block.global}}
@isInvalid={{block.globalError}}
{{on "input" (fn this.updateGlobalValue blockIndex)}}
data-test-input="global-{{blockIndex}}"
as |F|
>
<F.Label>Global</F.Label>
{{#if block.globalError}}
<F.Error data-test-validation-error="global">{{block.globalError}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{/if}}
</Grid.Item>
{{! Org Column }}
<Grid.Item>
<Hds::Layout::Flex @gap="8" @align="end">
<Hds::Form::TextInput::Field
@value={{org.name}}
@isInvalid={{org.error}}
{{on "input" (fn this.updateOrgValue block org)}}
data-test-input="org-{{orgIndex}}"
as |F|
>
<F.Label>Org</F.Label>
{{#if org.error}}
<F.Error data-test-validation-error="org">{{org.error}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{#unless (eq orgIndex 0)}}
<Hds::Button
@color="secondary"
@icon="trash"
@isIconOnly={{true}}
@text="Delete org"
class="align-self-end"
{{on "click" (fn this.removeOrg block org)}}
data-test-button="delete org"
/>
{{/unless}}
</Hds::Layout::Flex>
{{! Show Add button under last input }}
{{#if (eq orgIndex (sub block.orgs.length 1))}}
<Hds::Button
@color="secondary"
@icon="plus"
@size="small"
@text="Add"
{{on "click" (fn this.addOrg block)}}
class="has-top-margin-s"
data-test-button="add org"
/>
{{/if}}
</Grid.Item>
{{! Project Column }}
<Grid.Item>
{{#each org.projects key="@index" as |project projectIndex|}}
<div class="{{if (gt projectIndex 0) 'has-top-margin-s'}}">
<Hds::Layout::Flex @gap="8" @align="end">
<Hds::Form::TextInput::Field
@value={{project.name}}
@isInvalid={{project.error}}
{{on "input" (fn this.updateProjectValue block org project)}}
data-test-input="project-{{projectIndex}}"
as |F|
>
<F.Label>Project</F.Label>
{{#if project.error}}
<F.Error data-test-validation-error="config">{{project.error}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{#unless (eq projectIndex 0)}}
<Hds::Button
@color="secondary"
@icon="trash"
@isIconOnly={{true}}
@text="Delete project"
class="align-self-end"
{{on "click" (fn this.removeProject block org project)}}
data-test-button="delete project"
/>
{{/unless}}
</Hds::Layout::Flex>
</div>
{{/each}}
<Hds::Button
@color="secondary"
@icon="plus"
@text="Add"
@size="small"
{{on "click" (fn this.addProject block org)}}
class="has-top-margin-s"
data-test-button="add project"
/>
</Grid.Item>
</Hds::Layout::Grid>
{{/each}}
</:content>
</A.Item>
</Hds::Accordion>
</Hds::Card::Container>
</LG.Item>
{{/each}}
<LG.Item @colspan={{4}}>
<Hds::Button
@text="Add namespace"
@color="secondary"
@icon="plus"
{{on "click" this.addBlock}}
data-test-button="add namespace"
/>
</LG.Item>
{{#if this.shouldShowTreeChart}}
<LG.Item @colspan={{4}}>
<Hds::Text::Display @tag="h3" @size="300" class="has-bottom-padding-s">Namespace Structure Preview</Hds::Text::Display>
<TreeChart @data={{this.treeData}} @options={{this.treeChartOptions}} class="tree has-padding-m" data-test-tree />
</LG.Item>
{{/if}}
</Hds::Layout::Grid>

View file

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

View file

@ -0,0 +1,106 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (eq @wizardState.securityPolicyChoice this.policy.STRICT)}}
<div {{did-insert this.updateCodeSnippet}}>
<Hds::Text::Display @tag="h1" @size="400" class="has-bottom-padding-l" data-test-step-title>Choose your implementation
method</Hds::Text::Display>
<Hds::Form::RadioCard::Group @name="namespace-creation" @alignment="center" as |G|>
{{#each this.creationMethodOptions as |option|}}
<G.RadioCard
@alignment="left"
@checked={{eq option.label this.creationMethodChoice}}
{{on "change" (fn this.onChange option)}}
data-test-radio-card={{option.label}}
as |R|
>
<R.Icon @name={{option.icon}} />
<R.Label>{{option.label}}</R.Label>
{{#if option.isRecommended}}
<R.Badge @color="highlight" @text="Recommended" />
{{/if}}
<R.Description>{{option.description}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#unless (eq this.creationMethodChoice this.methods.UI)}}
<Hds::Text::Display @tag="h2" @size="400" class="has-bottom-padding-s has-top-margin-xl">Edit configuration</Hds::Text::Display>
<Hds::Text::Body @tag="p" class="has-bottom-padding-l">
{{#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
<Hds::Link::Inline @href={{doc-link "/terraform/tutorials/configuration-language"}}>Terraform guide</Hds::Link::Inline>.
{{/if}}
</Hds::Text::Body>
<Hds::Card::Container @hasBorder={{true}} class="has-top-padding-m has-bottom-padding-m side-padding-24">
{{#if (eq this.creationMethodChoice this.methods.APICLI)}}
<Hds::Tabs @onClickTab={{this.onClickTab}} as |T|>
{{#each this.tabOptions as |tabName|}}
<T.Tab data-test-tab={{tabName}}>{{tabName}}</T.Tab>
<T.Panel>
<Hds::CodeBlock
@language="bash"
@value={{this.snippet}}
@hasLineNumbers={{true}}
@hasCopyButton={{true}}
data-test-field="snippets"
/>
</T.Panel>
{{/each}}
</Hds::Tabs>
{{else}}
<Hds::CodeBlock
@language="hcl"
@value={{this.snippet}}
@hasLineNumbers={{true}}
@hasCopyButton={{true}}
data-test-field="snippets"
/>
{{/if}}
</Hds::Card::Container>
{{/unless}}
</div>
{{else}}
<div class="is-flex-center has-bottom-margin-l">
<Hds::Icon @color="success" @name="check-circle-fill" />
<Hds::Text::Display @tag="h2" @size="400" @weight="bold" class="has-left-margin-xs" data-test-step-title>No action
needed, you're all set.</Hds::Text::Display>
</div>
<Hds::Card::Container @hasBorder={{true}} class="has-padding-m has-bottom-margin-l">
<Hds::Text::Display @tag="h3" @size="300" @weight="semibold" class="has-bottom-padding-m">Next up: build out your access
lists and identities</Hds::Text::Display>
<p class="has-bottom-margin-s">
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.
</p>
<Hds::Text::Body @tag="h4" @weight="semibold" @size="300" class="has-bottom-padding-s">Why use ACL and identities?</Hds::Text::Body>
<ul class="bullet has-bottom-margin-s">
<li>Simplified policy writing and secret access paths.</li>
<li>No performance or scaling issues associated with namespaces.</li>
<li>Much more flexible than namespaces.</li>
</ul>
<Hds::Button
@color="secondary"
@icon="arrow-right"
@iconPosition="trailing"
@route="vault.cluster.access.identity.index"
@model="entities"
@text="Set up identities"
data-test-button="identities"
/>
</Hds::Card::Container>
<Hds::Link::Standalone
@icon="external-link"
@iconPosition="trailing"
@text="Learn more about namespaces"
@href={{doc-link "/vault/docs/enterprise/namespaces/namespace-structure"}}
/>
{{/if}}

View file

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

View file

@ -0,0 +1,38 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Flex @align="start" @direction="column">
<Hds::Text::Display @tag="h1" @size="400" @weight="bold">
Welcome to Namespaces
</Hds::Text::Display>
<div class="flex column-gap-8 has-bottom-margin-xs has-top-margin-xs">
<Hds::Badge @text="Optional" />
<Hds::Badge @text="Setup time: 15min" @type="outlined" />
</div>
<Hds::Text::Body @tag="p" class="has-bottom-margin-xl">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.</Hds::Text::Body>
<div class="flex has-bottom-margin-s">
<Hds::Icon @name="service" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Use for multi-tenancy: strict
<strong>administrative and configuration isolation</strong>
between business units and client environments.</Hds::Text::Body>
</div>
<div class="flex has-bottom-margin-s">
<Hds::Icon @name="database" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Namespaces allow you to segment your cluster into
<strong>separate logical partitions</strong>, with isolated data, policy, and tokens.</Hds::Text::Body>
</div>
<div class="flex">
<Hds::Icon @name="org" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Namespaces should not be used like folders. Use them for
<strong>large-scale, highly-regulated</strong>
environments.</Hds::Text::Body>
</div>
</Hds::Layout::Flex>
<div>
<img src={{img-path "~/namespaces-welcome.png"}} alt="namespace hierarchy example" />
<Hds::Text::Body @align="center" @tag="p">Namespaces provide necessary isolation based on your companys organization and
access requirements.</Hds::Text::Body>
</div>

View file

@ -1,40 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="wizard" data-test-quickstart-content>
<Hds::Stepper::Nav
class="has-top-margin-xl has-bottom-margin-xl"
@isInteractive={{false}}
@currentStep={{@currentStep}}
@steps={{@steps}}
/>
<div class="content" tabindex="0">
{{yield to="content"}}
</div>
<div class="button-bar">
{{#if (gt @currentStep 0)}}
<Hds::Button
@text="Back"
@color="tertiary"
@icon="chevron-left"
{{on "click" (fn this.onStepChange -1)}}
data-test-back-button
/>
{{/if}}
<Hds::ButtonSet>
<Hds::Button @text="Exit" @color="secondary" {{on "click" @onDismiss}} data-test-cancel />
{{#if this.isFinalStep}}
{{yield to="submit"}}
{{else}}
<Hds::Button @text="Next" {{on "click" (fn this.onStepChange 1)}} data-test-next-button />
{{/if}}
</Hds::ButtonSet>
</div>
</div>

View file

@ -0,0 +1,12 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="wizard welcome">
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l">
<Hds::Layout::Grid @columnWidth="50%" @gap="16">
{{yield to="welcome"}}
</Hds::Layout::Grid>
</Hds::Card::Container>
</div>

View file

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

View file

@ -199,6 +199,9 @@
}
// CHILD ELEMENT HELPERS
.align-self-flex-start {
align-self: flex-start;
}
.align-self-center {
align-self: center;

View file

@ -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<string>();
const middleLayers: { [topLayer: string]: Set<string> } = {};
const bottomLayers: { [middleKey: string]: Set<string> } = {};
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');
};

View file

@ -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;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

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

View file

@ -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) {

View file

@ -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]',

View file

@ -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`
<Wizard::Namespaces::NamespaceWizard
@onDismiss={{this.onDismiss}}
@onRefresh={{this.onRefresh}}
/>
`);
};
});
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');
});
});

View file

@ -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`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@welcomeDocLink={{this.welcomeDocLink}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:welcome>
<div>Some welcome content</div>
{{!-- TODO: This will change once the welcome page structure is defined in a follow up PR --}}
<Hds::Button @text="Dismiss" {{on "click" this.onDismiss}} />
<Hds::Button @text="Enter wizard" {{on "click" (fn (mut this.showWelcome) false)}} data-test-enter-wizard-button />
</:welcome>
<:quickstart>
<div data-test-quickstart-content>Quickstart content</div>
</:quickstart>
</Wizard>`);
// 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`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div data-test-quickstart-content>Quickstart content</div>
</:quickstart>
<:submit>
<Hds::Button @text="Custom Submit" {{on "click" this.onCustomSubmit}} data-test-custom-submit />
</: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`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
@ -119,24 +109,21 @@ module('Integration | Component | Wizard', function (hooks) {
test('it renders next button when not on final step', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@canProceed={{this.canProceed}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
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`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
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`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
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');
});