- {{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}}
+
+ {{/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$$
+
+
+
+
+
+
+
+
+
+ {{#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.
+
+
+
+

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