mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* 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:
parent
e3cdfec694
commit
c499aa5288
29 changed files with 1827 additions and 217 deletions
3
changelog/_11556.txt
Normal file
3
changelog/_11556.txt
Normal 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.
|
||||
```
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
56
ui/app/components/wizard/guided-setup.hbs
Normal file
56
ui/app/components/wizard/guided-setup.hbs
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}}
|
||||
46
ui/app/components/wizard/index.ts
Normal file
46
ui/app/components/wizard/index.ts
Normal 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;
|
||||
}
|
||||
39
ui/app/components/wizard/namespaces/namespace-wizard.hbs
Normal file
39
ui/app/components/wizard/namespaces/namespace-wizard.hbs
Normal 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>
|
||||
166
ui/app/components/wizard/namespaces/namespace-wizard.ts
Normal file
166
ui/app/components/wizard/namespaces/namespace-wizard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
ui/app/components/wizard/namespaces/step-1.hbs
Normal file
77
ui/app/components/wizard/namespaces/step-1.hbs
Normal 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>
|
||||
52
ui/app/components/wizard/namespaces/step-1.ts
Normal file
52
ui/app/components/wizard/namespaces/step-1.ts
Normal 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."],
|
||||
};
|
||||
}
|
||||
}
|
||||
209
ui/app/components/wizard/namespaces/step-2.hbs
Normal file
209
ui/app/components/wizard/namespaces/step-2.hbs
Normal 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>
|
||||
339
ui/app/components/wizard/namespaces/step-2.ts
Normal file
339
ui/app/components/wizard/namespaces/step-2.ts
Normal 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 !== '');
|
||||
}
|
||||
}
|
||||
106
ui/app/components/wizard/namespaces/step-3.hbs
Normal file
106
ui/app/components/wizard/namespaces/step-3.hbs
Normal 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}}
|
||||
121
ui/app/components/wizard/namespaces/step-3.ts
Normal file
121
ui/app/components/wizard/namespaces/step-3.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
ui/app/components/wizard/namespaces/welcome.hbs
Normal file
38
ui/app/components/wizard/namespaces/welcome.hbs
Normal 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 company’s organization and
|
||||
access requirements.</Hds::Text::Body>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
12
ui/app/components/wizard/welcome.hbs
Normal file
12
ui/app/components/wizard/welcome.hbs
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,9 @@
|
|||
}
|
||||
|
||||
// CHILD ELEMENT HELPERS
|
||||
.align-self-flex-start {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.align-self-center {
|
||||
align-self: center;
|
||||
|
|
|
|||
185
ui/lib/core/addon/utils/code-generators/namespace-snippets.ts
Normal file
185
ui/lib/core/addon/utils/code-generators/namespace-snippets.ts
Normal 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');
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
BIN
ui/public/images/multi-namespace.gif
Normal file
BIN
ui/public/images/multi-namespace.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
ui/public/images/namespaces-welcome.png
Normal file
BIN
ui/public/images/namespaces-welcome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
145
ui/tests/integration/components/page/namespaces-wizard-test.js
Normal file
145
ui/tests/integration/components/page/namespaces-wizard-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue