diff --git a/ui/app/components/auth-info.js b/ui/app/components/auth-info.js index ba44e5c71c1..50e9b3c495f 100644 --- a/ui/app/components/auth-info.js +++ b/ui/app/components/auth-info.js @@ -1,9 +1,10 @@ import Ember from 'ember'; -export default Ember.Component.extend({ - auth: Ember.inject.service(), - - routing: Ember.inject.service('-routing'), +const { Component, inject, computed, run } = Ember; +export default Component.extend({ + auth: inject.service(), + wizard: inject.service(), + routing: inject.service('-routing'), transitionToRoute: function() { var router = this.get('routing.router'); @@ -12,12 +13,15 @@ export default Ember.Component.extend({ classNames: 'user-menu auth-info', - isRenewing: Ember.computed.or('fakeRenew', 'auth.isRenewing'), + isRenewing: computed.or('fakeRenew', 'auth.isRenewing'), actions: { + restartGuide() { + this.get('wizard').restartGuide(); + }, renewToken() { this.set('fakeRenew', true); - Ember.run.later(() => { + run.later(() => { this.set('fakeRenew', false); this.get('auth').renew(); }, 200); diff --git a/ui/app/components/doc-link.js b/ui/app/components/doc-link.js index 892cb2ffe06..6a2a9088751 100644 --- a/ui/app/components/doc-link.js +++ b/ui/app/components/doc-link.js @@ -5,6 +5,7 @@ const { Component, computed } = Ember; export default Component.extend({ tagName: 'a', + classNames: ['doc-link'], attributeBindings: ['target', 'rel', 'href'], layout: hbs`{{yield}}`, @@ -14,6 +15,6 @@ export default Component.extend({ path: '/', href: computed('path', function() { - return `https://www.vaultproject.io/docs${this.get('path')}`; + return `https://www.vaultproject.io${this.get('path')}`; }), }); diff --git a/ui/app/components/generate-credentials.js b/ui/app/components/generate-credentials.js index b8ddd912242..d6851ad94f5 100644 --- a/ui/app/components/generate-credentials.js +++ b/ui/app/components/generate-credentials.js @@ -26,6 +26,7 @@ const MODEL_TYPES = { }; export default Component.extend({ + wizard: inject.service(), store: inject.service(), routing: inject.service('-routing'), // set on the component @@ -57,6 +58,16 @@ export default Component.extend({ this.createOrReplaceModel(); }, + didReceiveAttrs() { + if (this.get('wizard.featureState') === 'displayRole') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'CONTINUE', + this.get('backend.type') + ); + } + }, + willDestroy() { this.get('model').unloadRecord(); this._super(...arguments); @@ -84,10 +95,21 @@ export default Component.extend({ create() { let model = this.get('model'); this.set('loading', true); - model.save().finally(() => { - model.set('hasGenerated', true); - this.set('loading', false); - }); + this.model + .save() + .catch(() => { + if (this.get('wizard.featureState') === 'credentials') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'ERROR', + this.get('backend.type') + ); + } + }) + .finally(() => { + model.set('hasGenerated', true); + this.set('loading', false); + }); }, codemirrorUpdated(attr, val, codemirror) { diff --git a/ui/app/components/i-con.js b/ui/app/components/i-con.js index d079d451aa1..cc82e8f5155 100644 --- a/ui/app/components/i-con.js +++ b/ui/app/components/i-con.js @@ -3,6 +3,10 @@ import hbs from 'htmlbars-inline-precompile'; const { computed } = Ember; const GLYPHS_WITH_SVG_TAG = [ + 'learn', + 'video', + 'tour', + 'stopwatch', 'download', 'folder', 'file', @@ -16,7 +20,7 @@ const GLYPHS_WITH_SVG_TAG = [ 'upload', 'control-lock', 'edition-enterprise', - 'edition-oss' + 'edition-oss', ]; export default Ember.Component.extend({ @@ -40,11 +44,12 @@ export default Ember.Component.extend({ glyph: null, excludeSVG: computed('glyph', function() { - return GLYPHS_WITH_SVG_TAG.includes(this.get('glyph')); + let glyph = this.get('glyph'); + return glyph.startsWith('enable/') || GLYPHS_WITH_SVG_TAG.includes(glyph); }), - size: computed(function() { - return 12; + size: computed('glyph', function() { + return this.get('glyph').startsWith('enable/') ? 48 : 12; }), partialName: computed('glyph', function() { diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index d9c48dfd68e..4c0abbb047e 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -1,12 +1,15 @@ import Ember from 'ember'; import { task } from 'ember-concurrency'; import { methods } from 'vault/helpers/mountable-auth-methods'; +import { engines } from 'vault/helpers/mountable-secret-engines'; -const { inject } = Ember; +const { inject, computed, Component } = Ember; const METHODS = methods(); +const ENGINES = engines(); -export default Ember.Component.extend({ +export default Component.extend({ store: inject.service(), + wizard: inject.service(), flashMessages: inject.service(), routing: inject.service('-routing'), @@ -38,23 +41,29 @@ export default Ember.Component.extend({ */ mountModel: null, + showConfig: false, + init() { this._super(...arguments); const type = this.get('mountType'); const modelType = type === 'secret' ? 'secret-engine' : 'auth-method'; const model = this.get('store').createRecord(modelType); this.set('mountModel', model); - this.changeConfigModel(model.get('type')); }, + mountTypes: computed('mountType', function() { + return this.get('mountType') === 'secret' ? ENGINES : METHODS; + }), + willDestroy() { // if unsaved, we want to unload so it doesn't show up in the auth mount list this.get('mountModel').rollbackAttributes(); }, getConfigModelType(methodType) { + let mountType = this.get('mountType'); let noConfig = ['approle']; - if (noConfig.includes(methodType)) { + if (mountType === 'secret' || noConfig.includes(methodType)) { return; } if (methodType === 'aws') { @@ -64,27 +73,32 @@ export default Ember.Component.extend({ }, changeConfigModel(methodType) { - const mount = this.get('mountModel'); - const configRef = mount.hasMany('authConfigs').value(); - const currentConfig = configRef.get('firstObject'); + let mount = this.get('mountModel'); + if (this.get('mountType') === 'secret') { + return; + } + let configRef = mount.hasMany('authConfigs').value(); + let currentConfig = configRef.get('firstObject'); if (currentConfig) { // rollbackAttributes here will remove the the config model from the store // because `isNew` will be true currentConfig.rollbackAttributes(); + currentConfig.unloadRecord(); } - const configType = this.getConfigModelType(methodType); + let configType = this.getConfigModelType(methodType); if (!configType) return; - const config = this.get('store').createRecord(configType); + let config = this.get('store').createRecord(configType); config.set('backend', mount); }, checkPathChange(type) { - const mount = this.get('mountModel'); - const currentPath = mount.get('path'); + let mount = this.get('mountModel'); + let currentPath = mount.get('path'); + let list = this.get('mountTypes'); // if the current path matches a type (meaning the user hasn't altered it), // change it here to match the new type - const isUnchanged = METHODS.findBy('type', currentPath); - if (isUnchanged) { + let isUnchanged = list.findBy('type', currentPath); + if (!currentPath || isUnchanged) { mount.set('path', type); } }, @@ -101,6 +115,10 @@ export default Ember.Component.extend({ this.get('flashMessages').success( `Successfully mounted ${type} ${this.get('mountType')} method at ${path}.` ); + if (this.get('mountType') === 'secret') { + yield this.get('onMountSuccess')(type, path); + return; + } yield this.get('saveConfig').perform(mountModel); }).drop(), @@ -111,11 +129,16 @@ export default Ember.Component.extend({ try { if (config && Object.keys(config.changedAttributes()).length) { yield config.save(); + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'CONTINUE', + this.get('mountModel').get('type') + ); this.get('flashMessages').success( `The config for ${type} ${this.get('mountType')} method at ${path} was saved successfully.` ); } - yield this.get('onMountSuccess')(); + yield this.get('onMountSuccess')(type, path); } catch (err) { this.get('flashMessages').danger( `There was an error saving the configuration for ${type} ${this.get( @@ -129,9 +152,27 @@ export default Ember.Component.extend({ actions: { onTypeChange(path, value) { if (path === 'type') { + this.get('wizard').set('componentState', value); this.changeConfigModel(value); this.checkPathChange(value); } }, + + toggleShowConfig(value) { + this.set('showConfig', value); + if (value === true && this.get('wizard.featureState') === 'idle') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'CONTINUE', + this.get('mountModel').get('type') + ); + } else { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'RESET', + this.get('mountModel').get('type') + ); + } + }, }, }); diff --git a/ui/app/components/outer-html.js b/ui/app/components/outer-html.js new file mode 100644 index 00000000000..f071ff55109 --- /dev/null +++ b/ui/app/components/outer-html.js @@ -0,0 +1,11 @@ +// THIS COMPONENT IS ONLY FOR EXTENDING +// You should use this component if you want to use outerHTML symantics +// in your components - this is the default for upcoming Glimmer components +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); + +// yep! that's it, it's more of a way to keep track of what components +// use tagless semantics to make the upgrade to glimmer components easier diff --git a/ui/app/components/popup-menu.js b/ui/app/components/popup-menu.js index e3ac4fb5c0a..ced037466a9 100644 --- a/ui/app/components/popup-menu.js +++ b/ui/app/components/popup-menu.js @@ -1,5 +1,5 @@ import Ember from 'ember'; export default Ember.Component.extend({ - tagName: '', + tagName: 'span', }); diff --git a/ui/app/components/replication-summary.js b/ui/app/components/replication-summary.js index 35ae26fe1df..3e68265e78c 100644 --- a/ui/app/components/replication-summary.js +++ b/ui/app/components/replication-summary.js @@ -2,7 +2,7 @@ import Ember from 'ember'; import decodeConfigFromJWT from 'vault/utils/decode-config-from-jwt'; import ReplicationActions from 'vault/mixins/replication-actions'; -const { computed, get } = Ember; +const { computed, get, Component, inject } = Ember; const DEFAULTS = { mode: 'primary', @@ -17,7 +17,9 @@ const DEFAULTS = { replicationMode: 'dr', }; -export default Ember.Component.extend(ReplicationActions, DEFAULTS, { +export default Component.extend(ReplicationActions, DEFAULTS, { + wizard: inject.service(), + version: inject.service(), didReceiveAttrs() { this._super(...arguments); const initialReplicationMode = this.get('initialReplicationMode'); @@ -28,7 +30,6 @@ export default Ember.Component.extend(ReplicationActions, DEFAULTS, { showModeSummary: false, initialReplicationMode: null, cluster: null, - version: Ember.inject.service(), replicationAttrs: computed.alias('cluster.replicationAttrs'), @@ -55,7 +56,6 @@ export default Ember.Component.extend(ReplicationActions, DEFAULTS, { ) { return false; } - return true; } ), @@ -66,7 +66,12 @@ export default Ember.Component.extend(ReplicationActions, DEFAULTS, { actions: { onSubmit(/*action, mode, data, event*/) { - return this.submitHandler(...arguments); + let promise = this.submitHandler(...arguments); + let wizard = this.get('wizard'); + promise.then(() => { + wizard.transitionFeatureMachine(wizard.get('featureState'), 'ENABLEREPLICATION'); + }); + return promise; }, clear() { diff --git a/ui/app/components/role-aws-edit.js b/ui/app/components/role-aws-edit.js index 23e6f6ae595..4021ce46b48 100644 --- a/ui/app/components/role-aws-edit.js +++ b/ui/app/components/role-aws-edit.js @@ -5,10 +5,6 @@ const { get, set } = Ember; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; export default RoleEdit.extend({ - init() { - this._super(...arguments); - }, - actions: { createOrUpdate(type, event) { event.preventDefault(); @@ -21,13 +17,13 @@ export default RoleEdit.extend({ } var credential_type = get(this, 'model.credential_type'); - if (credential_type == "iam_user") { + if (credential_type == 'iam_user') { set(this, 'model.role_arns', []); } - if (credential_type == "assumed_role") { + if (credential_type == 'assumed_role') { set(this, 'model.policy_arns', []); } - if (credential_type == "federation_token") { + if (credential_type == 'federation_token') { set(this, 'model.role_arns', []); set(this, 'model.policy_arns', []); } diff --git a/ui/app/components/role-edit.js b/ui/app/components/role-edit.js index 4cc43763f68..dd179d685e4 100644 --- a/ui/app/components/role-edit.js +++ b/ui/app/components/role-edit.js @@ -2,7 +2,7 @@ import Ember from 'ember'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; import keys from 'vault/lib/keycodes'; -const { get, set, computed } = Ember; +const { get, set, computed, inject } = Ember; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; @@ -12,8 +12,31 @@ export default Ember.Component.extend(FocusOnInsertMixin, { onDataChange: () => {}, refresh: 'refresh', model: null, - routing: Ember.inject.service('-routing'), + routing: inject.service('-routing'), + wizard: inject.service(), requestInFlight: computed.or('model.isLoading', 'model.isReloading', 'model.isSaving'), + + didReceiveAttrs() { + this._super(...arguments); + if ( + (this.get('wizard.featureState') === 'details' && this.get('mode') === 'create') || + (this.get('wizard.featureState') === 'role' && this.get('mode') === 'show') + ) { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'CONTINUE', + this.get('backendType') + ); + } + if (this.get('wizard.featureState') === 'displayRole') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'NOOP', + this.get('backendType') + ); + } + }, + willDestroyElement() { const model = this.get('model'); if (get(model, 'isError')) { @@ -49,6 +72,9 @@ export default Ember.Component.extend(FocusOnInsertMixin, { const model = get(this, 'model'); return model[method]().then(() => { if (!Ember.get(model, 'isError')) { + if (this.get('wizard.featureState') === 'role') { + this.get('wizard').transitionFeatureMachine('role', 'CONTINUE', this.get('backendType')); + } successCallback(model); } }); diff --git a/ui/app/components/role-pki-edit.js b/ui/app/components/role-pki-edit.js index 25d06d37aaf..01340509501 100644 --- a/ui/app/components/role-pki-edit.js +++ b/ui/app/components/role-pki-edit.js @@ -1,3 +1,8 @@ import RoleEdit from './role-edit'; -export default RoleEdit.extend({}); +export default RoleEdit.extend({ + init() { + this._super(...arguments); + this.set('backendType', 'pki'); + }, +}); diff --git a/ui/app/components/role-ssh-edit.js b/ui/app/components/role-ssh-edit.js index 25d06d37aaf..7e8c5b5d67d 100644 --- a/ui/app/components/role-ssh-edit.js +++ b/ui/app/components/role-ssh-edit.js @@ -1,3 +1,8 @@ import RoleEdit from './role-edit'; -export default RoleEdit.extend({}); +export default RoleEdit.extend({ + init() { + this._super(...arguments); + this.set('backendType', 'ssh'); + }, +}); diff --git a/ui/app/components/secret-edit.js b/ui/app/components/secret-edit.js index 45372cca492..ff115506a37 100644 --- a/ui/app/components/secret-edit.js +++ b/ui/app/components/secret-edit.js @@ -6,7 +6,7 @@ import KVObject from 'vault/lib/kv-object'; const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; -const { get, computed } = Ember; +const { get, computed, inject } = Ember; export default Ember.Component.extend(FocusOnInsertMixin, { // a key model @@ -35,6 +35,8 @@ export default Ember.Component.extend(FocusOnInsertMixin, { hasLintError: false, + wizard: inject.service(), + init() { this._super(...arguments); const secrets = this.get('key.secretData'); @@ -45,6 +47,11 @@ export default Ember.Component.extend(FocusOnInsertMixin, { this.set('preferAdvancedEdit', true); } this.checkRows(); + if (this.get('wizard.featureState') === 'details' && this.get('mode') === 'create') { + let engine = this.get('key').backend.includes('kv') ? 'kv' : this.get('key').backend; + this.get('wizard').transitionFeatureMachine('details', 'CONTINUE', engine); + } + if (this.get('mode') === 'edit') { this.send('addRow'); } @@ -144,6 +151,9 @@ export default Ember.Component.extend(FocusOnInsertMixin, { return model[method]().then(() => { if (!Ember.get(model, 'isError')) { + if (this.get('wizard.featureState') === 'secret') { + this.get('wizard').transitionFeatureMachine('secret', 'CONTINUE'); + } successCallback(key); } }); diff --git a/ui/app/components/shamir-flow.js b/ui/app/components/shamir-flow.js index 7b0647b1c8d..48cd5ab12e7 100644 --- a/ui/app/components/shamir-flow.js +++ b/ui/app/components/shamir-flow.js @@ -26,15 +26,20 @@ export default Component.extend(DEFAULTS, { buttonText: 'Submit', thresholdPath: 'required', generateAction: false, - encoded_token: null, init() { + this._super(...arguments); if (this.get('fetchOnInit')) { this.attemptProgress(); } - return this._super(...arguments); }, + didInsertElement() { + this._super(...arguments); + this.onUpdate(this.getProperties(Object.keys(DEFAULTS))); + }, + + onUpdate() {}, onShamirSuccess() {}, // can be overridden w/an attr isComplete(data) { @@ -56,17 +61,23 @@ export default Component.extend(DEFAULTS, { hasProgress: computed.gt('progress', 0), actionSuccess(resp) { - const { isComplete, onShamirSuccess, thresholdPath } = this.getProperties( + let { onUpdate, isComplete, onShamirSuccess, thresholdPath } = this.getProperties( + 'onUpdate', 'isComplete', 'onShamirSuccess', 'thresholdPath' ); + let threshold = get(resp, thresholdPath); + let props = { + ...resp, + threshold, + }; this.stopLoading(); - this.set('threshold', get(resp, thresholdPath)); - this.setProperties(resp); - if (isComplete(resp)) { + this.setProperties(props); + onUpdate(props); + if (isComplete(props)) { this.reset(); - onShamirSuccess(resp); + onShamirSuccess(props); } }, diff --git a/ui/app/components/tool-actions-form.js b/ui/app/components/tool-actions-form.js index 61c9bea3357..916a1a29bdb 100644 --- a/ui/app/components/tool-actions-form.js +++ b/ui/app/components/tool-actions-form.js @@ -22,6 +22,7 @@ const WRAPPING_ENDPOINTS = ['lookup', 'wrap', 'unwrap', 'rewrap']; export default Ember.Component.extend(DEFAULTS, { store: Ember.inject.service(), + wizard: Ember.inject.service(), // putting these attrs here so they don't get reset when you click back //random bytes: 32, @@ -76,11 +77,13 @@ export default Ember.Component.extend(DEFAULTS, { props = Ember.assign({}, props, { unwrap_data: secret }); } props = Ember.assign({}, props, secret); - if (resp && resp.wrap_info) { const keyName = action === 'rewrap' ? 'rewrap_token' : 'token'; props = Ember.assign({}, props, { [keyName]: resp.wrap_info.token }); } + if (props.token || props.rewrap_token || props.unwrap_data || action === 'lookup') { + this.get('wizard').transitionFeatureMachine(this.get('wizard.featureState'), 'CONTINUE'); + } setProperties(this, props); }, diff --git a/ui/app/components/transit-edit.js b/ui/app/components/transit-edit.js index 56e11870791..3928b86dd75 100644 --- a/ui/app/components/transit-edit.js +++ b/ui/app/components/transit-edit.js @@ -2,7 +2,7 @@ import Ember from 'ember'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; import keys from 'vault/lib/keycodes'; -const { get, set, computed } = Ember; +const { get, set, computed, inject } = Ember; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; @@ -11,8 +11,14 @@ export default Ember.Component.extend(FocusOnInsertMixin, { onDataChange: null, refresh: 'refresh', key: null, - routing: Ember.inject.service('-routing'), + routing: inject.service('-routing'), requestInFlight: computed.or('key.isLoading', 'key.isReloading', 'key.isSaving'), + wizard: inject.service(), + + init() { + this._super(...arguments); + }, + willDestroyElement() { const key = this.get('key'); if (get(key, 'isError')) { @@ -48,6 +54,13 @@ export default Ember.Component.extend(FocusOnInsertMixin, { const key = get(this, 'key'); return key[method]().then(() => { if (!Ember.get(key, 'isError')) { + if (this.get('wizard.featureState') === 'secret') { + this.get('wizard').transitionFeatureMachine('secret', 'CONTINUE'); + } else { + if (this.get('wizard.featureState') === 'encryption') { + this.get('wizard').transitionFeatureMachine('encryption', 'CONTINUE', 'transit'); + } + } successCallback(key); } }); diff --git a/ui/app/components/ui-wizard.js b/ui/app/components/ui-wizard.js new file mode 100644 index 00000000000..82a028cf3bf --- /dev/null +++ b/ui/app/components/ui-wizard.js @@ -0,0 +1,61 @@ +import Ember from 'ember'; +import { matchesState } from 'xstate'; + +const { inject, computed } = Ember; + +export default Ember.Component.extend({ + classNames: ['ui-wizard-container'], + wizard: inject.service(), + auth: inject.service(), + + shouldRender: computed('wizard.showWhenUnauthenticated', 'auth.currentToken', function() { + return this.get('auth.currentToken') || this.get('wizard.showWhenUnauthenticated'); + }), + currentState: computed.alias('wizard.currentState'), + featureState: computed.alias('wizard.featureState'), + featureComponent: computed.alias('wizard.featureComponent'), + tutorialComponent: computed.alias('wizard.tutorialComponent'), + componentState: computed.alias('wizard.componentState'), + nextFeature: computed.alias('wizard.nextFeature'), + nextStep: computed.alias('wizard.nextStep'), + + actions: { + dismissWizard() { + this.get('wizard').transitionTutorialMachine(this.get('currentState'), 'DISMISS'); + }, + + advanceWizard() { + let inInit = matchesState('init', this.get('wizard.currentState')); + let event = inInit ? this.get('wizard.initEvent') || 'CONTINUE' : 'CONTINUE'; + this.get('wizard').transitionTutorialMachine(this.get('currentState'), event); + }, + + advanceFeature() { + this.get('wizard').transitionFeatureMachine(this.get('featureState'), 'CONTINUE'); + }, + + finishFeature() { + this.get('wizard').transitionFeatureMachine(this.get('featureState'), 'DONE'); + }, + + repeatStep() { + this.get('wizard').transitionFeatureMachine( + this.get('featureState'), + 'REPEAT', + this.get('componentState') + ); + }, + + resetFeature() { + this.get('wizard').transitionFeatureMachine( + this.get('featureState'), + 'RESET', + this.get('componentState') + ); + }, + + pauseWizard() { + this.get('wizard').transitionTutorialMachine(this.get('currentState'), 'PAUSE'); + }, + }, +}); diff --git a/ui/app/components/wizard-content.js b/ui/app/components/wizard-content.js new file mode 100644 index 00000000000..402c0b7fd1c --- /dev/null +++ b/ui/app/components/wizard-content.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +const { Component, inject } = Ember; +export default Component.extend({ + wizard: inject.service(), + classNames: ['ui-wizard'], + glyph: null, + headerText: null, + actions: { + dismissWizard() { + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'DISMISS'); + }, + }, +}); diff --git a/ui/app/components/wizard-section.js b/ui/app/components/wizard-section.js new file mode 100644 index 00000000000..7163b9edfe0 --- /dev/null +++ b/ui/app/components/wizard-section.js @@ -0,0 +1,8 @@ +import outerHTMLComponent from './outer-html'; + +export default outerHTMLComponent.extend({ + headerText: null, + headerIcon: null, + docText: null, + docPath: null, +}); diff --git a/ui/app/components/wizard/features-selection.js b/ui/app/components/wizard/features-selection.js new file mode 100644 index 00000000000..dabc23e87ef --- /dev/null +++ b/ui/app/components/wizard/features-selection.js @@ -0,0 +1,75 @@ +import Ember from 'ember'; + +const { inject, computed } = Ember; + +export default Ember.Component.extend({ + wizard: inject.service(), + version: inject.service(), + init() { + this._super(...arguments); + this.maybeHideFeatures(); + }, + + maybeHideFeatures() { + if (this.get('showReplication') === false) { + let feature = this.get('allFeatures').findBy('key', 'replication'); + feature.show = false; + } + }, + + allFeatures: computed(function() { + return [ + { + key: 'secrets', + name: 'Secrets', + steps: ['Enabling a secrets engine', 'Adding a secret'], + selected: false, + show: true, + }, + { + key: 'authentication', + name: 'Authentication', + steps: ['Enabling an auth method', 'Managing your auth method'], + selected: false, + show: true, + }, + { + key: 'policies', + name: 'Policies', + steps: ['Choosing a policy type', 'Creating a policy', 'Deleting your policy', 'Other types of policies'], + selected: false, + show: true, + }, + { + key: 'replication', + name: 'Replication', + steps: ['Setting up replication', 'Your cluster information'], + selected: false, + show: true, + }, + { + key: 'tools', + name: 'Tools', + steps: ['Wrapping data', 'Lookup wrapped data', 'Rewrapping your data', 'Unwrapping your data'], + selected: false, + show: true, + }, + ]; + }), + + showReplication: computed('version.hasPerfReplication', 'version.hasDRReplication', function() { + return this.get('version.hasPerfReplication') || this.get('version.hasDRReplication'); + }), + + selectedFeatures: computed('allFeatures.@each.selected', function() { + return this.get('allFeatures').filterBy('selected').mapBy('key'); + }), + + actions: { + saveFeatures() { + let wizard = this.get('wizard'); + wizard.saveFeatures(this.get('selectedFeatures')); + wizard.transitionTutorialMachine('active.select', 'CONTINUE'); + }, + }, +}); diff --git a/ui/app/components/wizard/mounts-wizard.js b/ui/app/components/wizard/mounts-wizard.js new file mode 100644 index 00000000000..1a8edb58a88 --- /dev/null +++ b/ui/app/components/wizard/mounts-wizard.js @@ -0,0 +1,71 @@ +import Ember from 'ember'; +import { engines } from 'vault/helpers/mountable-secret-engines'; +import { methods } from 'vault/helpers/mountable-auth-methods'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +const supportedSecrets = supportedSecretBackends(); +import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; +const supportedAuth = supportedAuthBackends(); +const { inject, computed } = Ember; + +export default Ember.Component.extend({ + wizard: inject.service(), + featureState: computed.alias('wizard.featureState'), + currentState: computed.alias('wizard.currentState'), + currentMachine: computed.alias('wizard.currentMachine'), + mountSubtype: computed.alias('wizard.componentState'), + fullNextStep: computed.alias('wizard.nextStep'), + nextFeature: computed.alias('wizard.nextFeature'), + nextStep: computed('fullNextStep', function() { + return this.get('fullNextStep').split('.').lastObject; + }), + needsEncryption: computed('mountSubtype', function() { + return this.get('mountSubtype') === 'transit'; + }), + stepComponent: computed.alias('wizard.stepComponent'), + detailsComponent: computed('mountSubtype', function() { + let suffix = this.get('currentMachine') === 'secrets' ? 'engine' : 'method'; + return this.get('mountSubtype') ? `wizard/${this.get('mountSubtype')}-${suffix}` : null; + }), + isSupported: computed('mountSubtype', function() { + if (this.get('currentMachine') === 'secrets') { + return supportedSecrets.includes(this.get('mountSubtype')); + } else { + return supportedAuth.includes(this.get('mountSubtype')); + } + }), + mountName: computed('mountSubtype', function() { + if (this.get('currentMachine') === 'secrets') { + var secret = engines().find(engine => { + return engine.type === this.get('mountSubtype'); + }); + if (secret) { + return secret.displayName; + } + } else { + var auth = methods().find(method => { + return method.type === this.get('mountSubtype'); + }); + if (auth) { + return auth.displayName; + } + } + return null; + }), + actionText: computed('mountSubtype', function() { + switch (this.get('mountSubtype')) { + case 'aws': + return 'Generate Credential'; + case 'ssh': + return 'Sign Keys'; + case 'pki': + return 'Generate Certificate'; + default: + return null; + } + }), + + onAdvance() {}, + onRepeat() {}, + onReset() {}, + onDone() {}, +}); diff --git a/ui/app/controllers/vault/cluster/init.js b/ui/app/controllers/vault/cluster/init.js index c04dd306dcd..4b163894566 100644 --- a/ui/app/controllers/vault/cluster/init.js +++ b/ui/app/controllers/vault/cluster/init.js @@ -10,6 +10,8 @@ const DEFAULTS = { }; export default Ember.Controller.extend(DEFAULTS, { + wizard: Ember.inject.service(), + reset() { this.setProperties(DEFAULTS); }, @@ -17,6 +19,8 @@ export default Ember.Controller.extend(DEFAULTS, { initSuccess(resp) { this.set('loading', false); this.set('keyData', resp); + this.get('wizard').set('initEvent', 'SAVE'); + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'TOSAVE'); }, initError(e) { diff --git a/ui/app/controllers/vault/cluster/policies/create.js b/ui/app/controllers/vault/cluster/policies/create.js index 1787086e22f..862d146bb46 100644 --- a/ui/app/controllers/vault/cluster/policies/create.js +++ b/ui/app/controllers/vault/cluster/policies/create.js @@ -5,7 +5,6 @@ import PolicyEditController from 'vault/mixins/policy-edit-controller'; export default Ember.Controller.extend(PolicyEditController, { showFileUpload: false, file: null, - actions: { setPolicyFromFile(index, fileInfo) { let { value, fileName } = fileInfo; diff --git a/ui/app/controllers/vault/cluster/policies/index.js b/ui/app/controllers/vault/cluster/policies/index.js index 8d02e0ad3c1..14934a2e1b4 100644 --- a/ui/app/controllers/vault/cluster/policies/index.js +++ b/ui/app/controllers/vault/cluster/policies/index.js @@ -3,6 +3,7 @@ let { inject } = Ember; export default Ember.Controller.extend({ flashMessages: inject.service(), + wizard: inject.service(), queryParams: { page: 'page', @@ -54,6 +55,9 @@ export default Ember.Controller.extend({ .destroyRecord() .then(() => { flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`); + if (this.get('wizard.featureState') === 'delete') { + this.get('wizard').transitionFeatureMachine('delete', 'CONTINUE', policyType); + } // this will clear the dataset cache on the store this.send('willTransition'); }) diff --git a/ui/app/controllers/vault/cluster/settings/auth/enable.js b/ui/app/controllers/vault/cluster/settings/auth/enable.js index 945a3bf89a3..3756cd71673 100644 --- a/ui/app/controllers/vault/cluster/settings/auth/enable.js +++ b/ui/app/controllers/vault/cluster/settings/auth/enable.js @@ -1,9 +1,13 @@ import Ember from 'ember'; export default Ember.Controller.extend({ + wizard: Ember.inject.service(), actions: { - onMountSuccess: function() { - return this.transitionToRoute('vault.cluster.access.methods'); + onMountSuccess: function(type) { + let transition = this.transitionToRoute('vault.cluster.access.methods'); + return transition.followRedirects().then(() => { + this.get('wizard').transitionFeatureMachine(this.get('wizard.featureState'), 'CONTINUE', type); + }); }, onConfigError: function(modelId) { return this.transitionToRoute('vault.cluster.settings.auth.configure', modelId); diff --git a/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js b/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js index 973d0544e96..eeabc2ef829 100644 --- a/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js +++ b/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js @@ -3,138 +3,24 @@ import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends const SUPPORTED_BACKENDS = supportedSecretBackends(); -const { computed } = Ember; - -export default Ember.Controller.extend({ - mountTypes: [ - { label: 'Active Directory', value: 'ad' }, - { label: 'AWS', value: 'aws' }, - { label: 'Consul', value: 'consul' }, - { label: 'Databases', value: 'database' }, - { label: 'Google Cloud', value: 'gcp' }, - { label: 'KV', value: 'kv' }, - { label: 'Nomad', value: 'nomad' }, - { label: 'PKI', value: 'pki' }, - { label: 'RabbitMQ', value: 'rabbitmq' }, - { label: 'SSH', value: 'ssh' }, - { label: 'Transit', value: 'transit' }, - { label: 'TOTP', value: 'totp' }, - { label: 'Cassandra', value: 'cassandra', deprecated: true }, - { label: 'MongoDB', value: 'mongodb', deprecated: true }, - { label: 'MSSQL', value: 'mssql', deprecated: true }, - { label: 'MySQL', value: 'mysql', deprecated: true }, - { label: 'PostgreSQL', value: 'postgresql', deprecated: true }, - ], - - selectedType: null, - selectedPath: null, - description: null, - default_lease_ttl: null, - max_lease_ttl: null, - showConfig: false, - local: false, - sealWrap: false, - version: 2, - - selection: computed('selectedType', function() { - return this.get('mountTypes').findBy('value', this.get('selectedType')); - }), - - flashMessages: Ember.inject.service(), - - reset() { - const defaultBackend = this.get('mountTypes.firstObject.value'); - this.setProperties({ - selectedPath: defaultBackend, - selectedType: defaultBackend, - description: null, - default_lease_ttl: null, - max_lease_ttl: null, - local: false, - showConfig: false, - sealWrap: false, - version: 2, - }); - }, - - init() { - this._super(...arguments); - this.reset(); - }, +const { inject, Controller } = Ember; +export default Controller.extend({ + wizard: inject.service(), actions: { - onTypeChange(val) { - const { selectedPath, selectedType } = this.getProperties('selectedPath', 'selectedType'); - this.set('selectedType', val); - if (selectedPath === selectedType) { - this.set('selectedPath', val); - } - }, - - toggleShowConfig() { - this.toggleProperty('showConfig'); - }, - - mountBackend() { - const { - selectedPath: path, - selectedType: type, - description, - default_lease_ttl, - local, - max_lease_ttl, - sealWrap, - version, - } = this.getProperties( - 'selectedPath', - 'selectedType', - 'description', - 'default_lease_ttl', - 'local', - 'max_lease_ttl', - 'sealWrap', - 'version' - ); - const currentModel = this.get('model'); - if (currentModel && currentModel.rollbackAttributes) { - currentModel.rollbackAttributes(); - } - let attrs = { - path, - type, - description, - local, - sealWrap, - }; - - if (this.get('showConfig')) { - attrs.config = { - defaultLeaseTtl: default_lease_ttl, - maxLeaseTtl: max_lease_ttl, - }; + onMountSuccess: function(type, path) { + let transition; + if (SUPPORTED_BACKENDS.includes(type)) { + transition = this.transitionToRoute('vault.cluster.secrets.backend.index', path); + } else { + transition = this.transitionToRoute('vault.cluster.secrets.backends'); } - - if (type === 'kv') { - attrs.options = { - version, - }; - } - - const model = this.store.createRecord('secret-engine', attrs); - - this.set('model', model); - model.save().then(() => { - this.reset(); - let transition; - if (SUPPORTED_BACKENDS.includes(type)) { - transition = this.transitionToRoute('vault.cluster.secrets.backend.index', path); - } else { - transition = this.transitionToRoute('vault.cluster.secrets.backends'); - } - transition.followRedirects().then(() => { - this.get('flashMessages').success(`Successfully mounted '${type}' at '${path}'!`); - }); + return transition.followRedirects().then(() => { + this.get('wizard').transitionFeatureMachine(this.get('wizard.featureState'), 'CONTINUE', type); }); }, + onConfigError: function(modelId) { + return this.transitionToRoute('vault.cluster.settings.configure-secret-backend', modelId); + }, }, }); diff --git a/ui/app/controllers/vault/cluster/unseal.js b/ui/app/controllers/vault/cluster/unseal.js index d20b3812511..5abcc4a8ce7 100644 --- a/ui/app/controllers/vault/cluster/unseal.js +++ b/ui/app/controllers/vault/cluster/unseal.js @@ -1,12 +1,20 @@ import Ember from 'ember'; export default Ember.Controller.extend({ + wizard: Ember.inject.service(), + actions: { - transitionToCluster() { + transitionToCluster(resp) { return this.get('model').reload().then(() => { + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'CONTINUE', resp); return this.transitionToRoute('vault.cluster', this.get('model.name')); }); }, + + setUnsealState(resp) { + this.get('wizard').set('componentState', resp); + }, + isUnsealed(data) { return data.sealed === false; }, diff --git a/ui/app/helpers/mountable-auth-methods.js b/ui/app/helpers/mountable-auth-methods.js index 06573e9dff9..e56d0872e3c 100644 --- a/ui/app/helpers/mountable-auth-methods.js +++ b/ui/app/helpers/mountable-auth-methods.js @@ -5,61 +5,76 @@ const MOUNTABLE_AUTH_METHODS = [ displayName: 'AppRole', value: 'approle', type: 'approle', + category: 'generic', }, { displayName: 'AWS', value: 'aws', type: 'aws', + category: 'cloud', }, { displayName: 'Azure', value: 'azure', type: 'azure', + category: 'cloud', }, { displayName: 'Google Cloud', value: 'gcp', type: 'gcp', + category: 'cloud', }, { displayName: 'GitHub', value: 'github', type: 'github', + category: 'cloud', }, { displayName: 'JWT/OIDC', value: 'jwt', type: 'jwt', + glyph: 'auth', + category: 'generic', }, { displayName: 'Kubernetes', value: 'kubernetes', type: 'kubernetes', + category: 'infra', }, { displayName: 'LDAP', value: 'ldap', type: 'ldap', + glyph: 'auth', + category: 'infra', }, { displayName: 'Okta', value: 'okta', type: 'okta', + category: 'infra', }, { displayName: 'RADIUS', value: 'radius', type: 'radius', + glyph: 'auth', + category: 'infra', }, { displayName: 'TLS Certificates', value: 'cert', type: 'cert', + category: 'generic', }, { displayName: 'Username & Password', value: 'userpass', type: 'userpass', + category: 'generic', }, ]; diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js new file mode 100644 index 00000000000..4c2d95032de --- /dev/null +++ b/ui/app/helpers/mountable-secret-engines.js @@ -0,0 +1,83 @@ +import Ember from 'ember'; + +const MOUNTABLE_SECRET_ENGINES = [ + { + displayName: 'Active Directory', + value: 'ad', + type: 'ad', + glyph: 'azure', + category: 'cloud', + }, + { + displayName: 'AWS', + value: 'aws', + type: 'aws', + category: 'cloud', + }, + { + displayName: 'Consul', + value: 'consul', + type: 'consul', + category: 'infra', + }, + { + displayName: 'Databases', + value: 'database', + type: 'database', + category: 'infra', + }, + { + displayName: 'Google Cloud', + value: 'gcp', + type: 'gcp', + category: 'cloud', + }, + { + displayName: 'KV', + value: 'kv', + type: 'kv', + category: 'generic', + }, + { + displayName: 'Nomad', + value: 'nomad', + type: 'nomad', + category: 'infra', + }, + { + displayName: 'PKI Certificates', + value: 'pki', + type: 'pki', + category: 'generic', + }, + { + displayName: 'RabbitMQ', + value: 'rabbitmq', + type: 'rabbitmq', + category: 'infra', + }, + { + displayName: 'SSH', + value: 'ssh', + type: 'ssh', + category: 'generic', + }, + { + displayName: 'Transit', + value: 'transit', + type: 'transit', + category: 'generic', + }, + { + displayName: 'TOTP', + value: 'totp', + type: 'totp', + category: 'generic', + }, +]; + +export function engines() { + return MOUNTABLE_SECRET_ENGINES; +} + +export default Ember.Helper.helper(engines); diff --git a/ui/app/machines/auth-machine.js b/ui/app/machines/auth-machine.js new file mode 100644 index 00000000000..cec39a9896b --- /dev/null +++ b/ui/app/machines/auth-machine.js @@ -0,0 +1,50 @@ +export default { + key: 'auth', + initial: 'idle', + on: { + RESET: 'idle', + DONE: 'complete', + }, + states: { + idle: { + onEntry: [ + { type: 'routeTransition', params: ['vault.cluster.settings.auth.enable'] }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/auth-idle' }, + ], + on: { + CONTINUE: 'enable', + }, + }, + enable: { + onEntry: [ + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/auth-enable' }, + ], + on: { + CONTINUE: 'list', + }, + }, + list: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/auth-list' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + DETAILS: 'details', + }, + }, + details: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/auth-details' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'complete', + }, + }, + complete: { + onEntry: ['completeFeature'], + }, + }, +}; diff --git a/ui/app/machines/policies-machine.js b/ui/app/machines/policies-machine.js new file mode 100644 index 00000000000..fce6140dc4d --- /dev/null +++ b/ui/app/machines/policies-machine.js @@ -0,0 +1,42 @@ +export default { + key: 'policies', + initial: 'idle', + states: { + idle: { + onEntry: [ + { type: 'routeTransition', params: ['vault.cluster.policies.index', 'acl'] }, + { type: 'render', level: 'feature', component: 'wizard/policies-intro' }, + ], + on: { + CONTINUE: 'create', + }, + }, + create: { + on: { + CONTINUE: 'details', + }, + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/policies-create' }], + }, + details: { + on: { + CONTINUE: 'delete', + }, + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/policies-details' }], + }, + delete: { + on: { + CONTINUE: 'others', + }, + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/policies-delete' }], + }, + others: { + on: { + CONTINUE: 'complete', + }, + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/policies-others' }], + }, + complete: { + onEntry: ['completeFeature'], + }, + }, +}; diff --git a/ui/app/machines/replication-machine.js b/ui/app/machines/replication-machine.js new file mode 100644 index 00000000000..2a0efc1d772 --- /dev/null +++ b/ui/app/machines/replication-machine.js @@ -0,0 +1,25 @@ +export default { + key: 'replication', + initial: 'setup', + states: { + setup: { + on: { + ENABLEREPLICATION: 'details', + }, + onEntry: [ + { type: 'routeTransition', params: ['vault.cluster.replication'] }, + { type: 'render', level: 'feature', component: 'wizard/replication-setup' }, + ], + }, + details: { + on: { + CONTINUE: 'complete', + }, + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/replication-details' }], + }, + complete: { + onEntry: ['completeFeature'], + on: { RESET: 'idle' }, + }, + }, +}; diff --git a/ui/app/machines/secrets-machine.js b/ui/app/machines/secrets-machine.js new file mode 100644 index 00000000000..8ac5eba70c1 --- /dev/null +++ b/ui/app/machines/secrets-machine.js @@ -0,0 +1,143 @@ +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +const supportedBackends = supportedSecretBackends(); + +export default { + key: 'secrets', + initial: 'idle', + on: { + RESET: 'idle', + DONE: 'complete', + ERROR: 'error', + }, + states: { + idle: { + onEntry: [ + { type: 'routeTransition', params: ['vault.cluster.settings.mount-secret-backend'] }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/secrets-idle' }, + ], + on: { + CONTINUE: 'enable', + }, + }, + enable: { + onEntry: [ + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/secrets-enable' }, + ], + on: { + CONTINUE: { + details: { cond: type => supportedBackends.includes(type) }, + list: { cond: type => !supportedBackends.includes(type) }, + }, + }, + }, + details: { + onEntry: [ + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/secrets-details' }, + ], + on: { + CONTINUE: { + role: { + cond: type => ['pki', 'aws', 'ssh'].includes(type), + }, + secret: { + cond: type => ['cubbyhole', 'database', 'gcp', 'kv', 'nomad', 'rabbitmq', 'totp'].includes(type), + }, + encryption: { + cond: type => type === 'transit', + }, + }, + }, + }, + encryption: { + onEntry: [ + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + { type: 'render', level: 'step', component: 'wizard/secrets-encryption' }, + ], + on: { + CONTINUE: 'display', + }, + }, + credentials: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-credentials' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'display', + }, + }, + role: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-role' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'displayRole', + }, + }, + displayRole: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-display-role' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'credentials', + }, + }, + secret: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-secret' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'display', + }, + }, + display: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-display' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + REPEAT: { + role: { + cond: type => ['pki', 'aws', 'ssh'].includes(type), + actions: [{ type: 'routeTransition', params: ['vault.cluster.secrets.backend.create-root'] }], + }, + secret: { + cond: type => ['cubbyhole', 'database', 'gcp', 'kv', 'nomad', 'rabbitmq', 'totp'].includes(type), + actions: [{ type: 'routeTransition', params: ['vault.cluster.secrets.backend.create-root'] }], + }, + encryption: { + cond: type => type === 'transit', + actions: [{ type: 'routeTransition', params: ['vault.cluster.secrets.backend.create-root'] }], + }, + }, + }, + }, + list: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/secrets-list' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'display', + }, + }, + error: { + onEntry: [ + { type: 'render', level: 'step', component: 'wizard/tutorial-error' }, + { type: 'render', level: 'feature', component: 'wizard/mounts-wizard' }, + ], + on: { + CONTINUE: 'complete', + }, + }, + complete: { + onEntry: ['completeFeature'], + }, + }, +}; diff --git a/ui/app/machines/tools-machine.js b/ui/app/machines/tools-machine.js new file mode 100644 index 00000000000..bde48906985 --- /dev/null +++ b/ui/app/machines/tools-machine.js @@ -0,0 +1,61 @@ +export default { + key: 'tools', + initial: 'wrap', + states: { + wrap: { + onEntry: [ + { type: 'routeTransition', params: ['vault.cluster.tools'] }, + { type: 'render', level: 'feature', component: 'wizard/tools-wrap' }, + ], + on: { + CONTINUE: 'wrapped', + }, + }, + wrapped: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-wrapped' }], + on: { + LOOKUP: 'lookup', + }, + }, + lookup: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-lookup' }], + on: { + CONTINUE: 'info', + }, + }, + info: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-info' }], + on: { + REWRAP: 'rewrap', + }, + }, + rewrap: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-rewrap' }], + on: { + CONTINUE: 'rewrapped', + }, + }, + rewrapped: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-rewrapped' }], + on: { + UNWRAP: 'unwrap', + }, + }, + unwrap: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-unwrap' }], + on: { + CONTINUE: 'unwrapped', + }, + }, + unwrapped: { + onEntry: [{ type: 'render', level: 'feature', component: 'wizard/tools-unwrapped' }], + on: { + CONTINUE: 'complete', + }, + }, + complete: { + onEntry: ['completeFeature'], + on: { RESET: 'idle' }, + }, + }, +}; diff --git a/ui/app/machines/tutorial-machine.js b/ui/app/machines/tutorial-machine.js new file mode 100644 index 00000000000..76c32d34bc1 --- /dev/null +++ b/ui/app/machines/tutorial-machine.js @@ -0,0 +1,109 @@ +export default { + key: 'tutorial', + initial: 'idle', + on: { + DISMISS: 'dismissed', + DONE: 'complete', + PAUSE: 'paused', + }, + states: { + init: { + key: 'init', + initial: 'idle', + on: { INITDONE: 'active.select' }, + onEntry: [ + 'showTutorialAlways', + { type: 'render', level: 'tutorial', component: 'wizard/tutorial-idle' }, + { type: 'render', level: 'feature', component: null }, + ], + onExit: ['showTutorialWhenAuthenticated'], + states: { + idle: { + on: { + START: 'active.setup', + SAVE: 'active.save', + UNSEAL: 'active.unseal', + LOGIN: 'active.login', + }, + }, + active: { + onEntry: { type: 'render', level: 'tutorial', component: 'wizard/tutorial-active' }, + states: { + setup: { + on: { TOSAVE: 'save' }, + onEntry: { type: 'render', level: 'feature', component: 'wizard/init-setup' }, + }, + save: { + on: { TOUNSEAL: 'unseal' }, + onEntry: { type: 'render', level: 'feature', component: 'wizard/init-save-keys' }, + }, + unseal: { + on: { TOLOGIN: 'login' }, + onEntry: { type: 'render', level: 'feature', component: 'wizard/init-unseal' }, + }, + login: { + onEntry: { type: 'render', level: 'feature', component: 'wizard/init-login' }, + }, + }, + }, + }, + }, + active: { + key: 'feature', + initial: 'select', + onEntry: { type: 'render', level: 'tutorial', component: 'wizard/tutorial-active' }, + states: { + select: { + on: { + CONTINUE: 'feature', + }, + onEntry: { type: 'render', level: 'feature', component: 'wizard/features-selection' }, + }, + feature: {}, + }, + }, + idle: { + on: { + INIT: 'init.idle', + AUTH: 'active.select', + CONTINUE: 'active', + }, + onEntry: [ + { type: 'render', level: 'feature', component: null }, + { type: 'render', level: 'step', component: null }, + { type: 'render', level: 'detail', component: null }, + { type: 'render', level: 'tutorial', component: 'wizard/tutorial-idle' }, + ], + }, + dismissed: { + onEntry: [ + { type: 'render', level: 'tutorial', component: null }, + { type: 'render', level: 'feature', component: null }, + { type: 'render', level: 'step', component: null }, + { type: 'render', level: 'detail', component: null }, + 'handleDismissed', + ], + }, + paused: { + on: { + CONTINUE: 'active.feature', + }, + onEntry: [ + { type: 'render', level: 'feature', component: null }, + { type: 'render', level: 'step', component: null }, + { type: 'render', level: 'detail', component: null }, + { type: 'render', level: 'tutorial', component: 'wizard/tutorial-paused' }, + 'handlePaused', + ], + onExit: ['handleResume'], + }, + complete: { + onEntry: [ + { type: 'render', level: 'feature', component: null }, + { type: 'render', level: 'step', component: null }, + { type: 'render', level: 'detail', component: null }, + { type: 'render', level: 'tutorial', component: 'wizard/tutorial-complete' }, + ], + }, + }, +}; diff --git a/ui/app/mixins/policy-edit-controller.js b/ui/app/mixins/policy-edit-controller.js index 69f425f7487..a353c6d8aad 100644 --- a/ui/app/mixins/policy-edit-controller.js +++ b/ui/app/mixins/policy-edit-controller.js @@ -4,6 +4,7 @@ let { inject } = Ember; export default Ember.Mixin.create({ flashMessages: inject.service(), + wizard: inject.service(), actions: { deletePolicy(model) { let policyType = model.get('policyType'); @@ -29,6 +30,9 @@ export default Ember.Mixin.create({ let name = model.get('name'); model.save().then(m => { flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully saved.`); + if (this.get('wizard.featureState') === 'create') { + this.get('wizard').transitionFeatureMachine('create', 'CONTINUE', policyType); + } return this.transitionToRoute('vault.cluster.policy.show', m.get('policyType'), m.get('name')); }); }, diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index 0dcb063e8a2..06e5768e760 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -2,7 +2,6 @@ import Ember from 'ember'; import DS from 'ember-data'; import { fragment } from 'ember-data-model-fragments/attributes'; import { queryRecord } from 'ember-computed-query'; -import { methods } from 'vault/helpers/mountable-auth-methods'; import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { memberAction } from 'ember-api-actions'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; @@ -10,8 +9,6 @@ import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; const { attr, hasMany } = DS; const { computed } = Ember; -const METHODS = methods(); - const configPath = function configPath(strings, key) { return function(...values) { return `${strings[0]}${values[key]}${strings[1]}`; @@ -19,15 +16,10 @@ const configPath = function configPath(strings, key) { }; export default DS.Model.extend({ authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }), - path: attr('string', { - defaultValue: METHODS[0].value, - }), + path: attr('string'), accessor: attr('string'), name: attr('string'), - type: attr('string', { - defaultValue: METHODS[0].value, - possibleValues: METHODS, - }), + type: attr('string'), // namespaces introduced types with a `ns_` prefix for built-in engines // so we need to strip that to normalize the type methodType: computed('type', function() { @@ -37,8 +29,14 @@ export default DS.Model.extend({ editType: 'textarea', }), config: fragment('mount-config', { defaultValue: {} }), - local: attr('boolean'), - sealWrap: attr('boolean'), + local: attr('boolean', { + helpText: + 'When replication is enabled, a local mount will not be replicated across clusters. This can only be specified at mount time.', + }), + sealWrap: attr('boolean', { + helpText: + 'When enabled - if a seal supporting seal wrapping is specified in the configuration, all critical security parameters (CSPs) in this backend will be seal wrapped. (For K/V mounts, all values will be seal wrapped.) This can only be specified at mount time.', + }), // used when the `auth` prefix is important, // currently only when setting perf mount filtering @@ -74,7 +72,7 @@ export default DS.Model.extend({ ], formFieldGroups: [ - { default: ['type', 'path'] }, + { default: ['path'] }, { 'Method Options': [ 'description', diff --git a/ui/app/models/mount-options.js b/ui/app/models/mount-options.js index 9870456f689..ad4d70def42 100644 --- a/ui/app/models/mount-options.js +++ b/ui/app/models/mount-options.js @@ -4,5 +4,9 @@ import Fragment from 'ember-data-model-fragments/fragment'; export default Fragment.extend({ version: attr('number', { label: 'Version', + helpText: + 'The KV Secrets engine can operate in different modes. Version 1 is the original generic secrets engine the allows for storing of static key/value pairs. Version 2 added more features including data versioning, TTLs, and check and set.', + possibleValues: [2, 1], + defaultFormValue: 2, }), }); diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index 93d2c28a3a6..f1f15b6f336 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -3,7 +3,7 @@ import DS from 'ember-data'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; import { fragment } from 'ember-data-model-fragments/attributes'; -import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; const { attr } = DS; const { computed } = Ember; @@ -16,12 +16,22 @@ export default DS.Model.extend({ path: attr('string'), accessor: attr('string'), name: attr('string'), - type: attr('string'), - description: attr('string'), + type: attr('string', { + label: 'Secret engine type', + }), + description: attr('string', { + editType: 'textarea', + }), config: fragment('mount-config', { defaultValue: {} }), options: fragment('mount-options', { defaultValue: {} }), - local: attr('boolean'), - sealWrap: attr('boolean'), + local: attr('boolean', { + helpText: + 'When replication is enabled, a local mount will not be replicated across clusters. This can only be specified at mount time.', + }), + sealWrap: attr('boolean', { + helpText: + 'When enabled - if a seal supporting seal wrapping is specified in the configuration, all critical security parameters (CSPs) in this backend will be seal wrapped. (For K/V mounts, all values will be seal wrapped.) This can only be specified at mount time.', + }), modelTypeForKV: computed('engineType', 'options.version', function() { let type = this.get('engineType'); @@ -33,21 +43,51 @@ export default DS.Model.extend({ return modelType; }), - formFields: [ - 'type', - 'path', - 'description', - 'accessor', - 'local', - 'sealWrap', - 'config.{defaultLeaseTtl,maxLeaseTtl}', - 'options.{version}', - ], + formFields: computed('engineType', function() { + let type = this.get('engineType'); + let fields = [ + 'type', + 'path', + 'description', + 'accessor', + 'local', + 'sealWrap', + 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}', + ]; + if (type === 'kv' || type === 'generic') { + fields.push('options.{version}'); + } + return fields; + }), + + formFieldGroups: computed('engineType', function() { + let type = this.get('engineType'); + let defaultGroup = { default: ['path'] }; + if (type === 'kv' || type === 'generic') { + defaultGroup.default.push('options.{version}'); + } + return [ + defaultGroup, + { + 'Method Options': [ + 'description', + 'config.listingVisibility', + 'local', + 'sealWrap', + 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}', + ], + }, + ]; + }), attrs: computed('formFields', function() { return expandAttributeMeta(this, this.get('formFields')); }), + fieldGroups: computed('formFieldGroups', function() { + return fieldToAttrs(this, this.get('formFieldGroups')); + }), + // namespaces introduced types with a `ns_` prefix for built-in engines // so we need to strip that to normalize the type engineType: computed('type', function() { diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 6c026e6de35..010c6f81204 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -5,6 +5,7 @@ const { inject } = Ember; export default Ember.Route.extend({ controlGroup: inject.service(), routing: inject.service('router'), + wizard: inject.service(), namespaceService: inject.service('namespace'), actions: { @@ -55,5 +56,27 @@ export default Ember.Route.extend({ return true; }, + didTransition() { + let wizard = this.get('wizard'); + + if (wizard.get('currentState') !== 'active.feature') { + return true; + } + Ember.run.next(() => { + let applicationURL = this.get('routing.currentURL'); + let activeRoute = this.get('routing.currentRouteName'); + + if (this.get('wizard.setURLAfterTransition')) { + this.set('wizard.setURLAfterTransition', false); + this.set('wizard.expectedURL', applicationURL); + this.set('wizard.expectedRouteName', activeRoute); + } + let expectedRouteName = this.get('wizard.expectedRouteName'); + if (this.get('routing').isActive(expectedRouteName) === false) { + wizard.transitionTutorialMachine(wizard.get('currentState'), 'PAUSE'); + } + }); + return true; + }, }, }); diff --git a/ui/app/routes/vault/cluster/access/control-group-accessor.js b/ui/app/routes/vault/cluster/access/control-group-accessor.js index 85481748500..3ceaa104a9a 100644 --- a/ui/app/routes/vault/cluster/access/control-group-accessor.js +++ b/ui/app/routes/vault/cluster/access/control-group-accessor.js @@ -12,7 +12,9 @@ export default Ember.Route.extend(UnloadModel, { }, model(params) { - return this.get('version.isOSS') ? null : this.store.findRecord('control-group', params.accessor); + return this.get('version').hasFeature('Control Groups') + ? this.store.findRecord('control-group', params.accessor) + : null; }, actions: { diff --git a/ui/app/routes/vault/cluster/access/control-groups.js b/ui/app/routes/vault/cluster/access/control-groups.js index 787768d89bc..26cd741c0db 100644 --- a/ui/app/routes/vault/cluster/access/control-groups.js +++ b/ui/app/routes/vault/cluster/access/control-groups.js @@ -12,6 +12,6 @@ export default Ember.Route.extend(UnloadModel, { }, model() { - return this.get('version.isOSS') ? null : this.store.createRecord('control-group'); + return this.get('version').hasFeature('Control Groups') ? this.store.createRecord('control-group') : null; }, }); diff --git a/ui/app/routes/vault/cluster/access/method/section.js b/ui/app/routes/vault/cluster/access/method/section.js index 2c94c6153b7..04edcdc71ae 100644 --- a/ui/app/routes/vault/cluster/access/method/section.js +++ b/ui/app/routes/vault/cluster/access/method/section.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import DS from 'ember-data'; export default Ember.Route.extend({ + wizard: Ember.inject.service(), model(params) { const { section_name: section } = params; if (section !== 'configuration') { @@ -9,7 +10,13 @@ export default Ember.Route.extend({ Ember.set(error, 'httpStatus', 404); throw error; } - return this.modelFor('vault.cluster.access.method'); + let backend = this.modelFor('vault.cluster.access.method'); + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'DETAILS', + backend.get('type') + ); + return backend; }, setupController(controller) { diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index e28d66e7c65..978e425df84 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -7,6 +7,7 @@ const { inject } = Ember; export default ClusterRouteBase.extend({ flashMessages: inject.service(), version: inject.service(), + wizard: inject.service(), beforeModel() { return this._super().then(() => { return this.get('version').fetchFeatures(); @@ -25,4 +26,15 @@ export default ClusterRouteBase.extend({ this.get('flashMessages').stickyInfo(config.welcomeMessage); } }, + activate() { + this.get('wizard').set('initEvent', 'LOGIN'); + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'TOLOGIN'); + }, + actions: { + willTransition(transition) { + if (transition.targetName !== this.routeName) { + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'INITDONE'); + } + }, + }, }); diff --git a/ui/app/routes/vault/cluster/init.js b/ui/app/routes/vault/cluster/init.js index 875583f717c..2972d08af8f 100644 --- a/ui/app/routes/vault/cluster/init.js +++ b/ui/app/routes/vault/cluster/init.js @@ -1 +1,14 @@ -export { default } from './cluster-route-base'; +import Ember from 'ember'; +import ClusterRoute from './cluster-route-base'; + +const { inject } = Ember; + +export default ClusterRoute.extend({ + wizard: inject.service(), + + activate() { + // always start from idle instead of using the current state + this.get('wizard').transitionTutorialMachine('idle', 'INIT'); + this.get('wizard').set('initEvent', 'START'); + }, +}); diff --git a/ui/app/routes/vault/cluster/policies/create.js b/ui/app/routes/vault/cluster/policies/create.js index 8604648e1e0..a5a1f41415d 100644 --- a/ui/app/routes/vault/cluster/policies/create.js +++ b/ui/app/routes/vault/cluster/policies/create.js @@ -5,9 +5,16 @@ import UnsavedModelRoute from 'vault/mixins/unsaved-model-route'; const { inject } = Ember; export default Ember.Route.extend(UnloadModelRoute, UnsavedModelRoute, { version: inject.service(), + wizard: inject.service(), model() { let policyType = this.policyType(); - + if ( + policyType === 'acl' && + this.get('wizard.currentMachine') === 'policies' && + this.get('wizard.featureState') === 'idle' + ) { + this.get('wizard').transitionFeatureMachine(this.get('wizard.featureState'), 'CONTINUE'); + } if (!this.get('version.hasSentinel') && policyType !== 'acl') { return this.transitionTo('vault.cluster.policies', policyType); } diff --git a/ui/app/routes/vault/cluster/policies/index.js b/ui/app/routes/vault/cluster/policies/index.js index 4b310d506c9..389ab9fd186 100644 --- a/ui/app/routes/vault/cluster/policies/index.js +++ b/ui/app/routes/vault/cluster/policies/index.js @@ -5,6 +5,7 @@ const { inject } = Ember; export default Ember.Route.extend(ClusterRoute, { version: inject.service(), + wizard: inject.service(), queryParams: { page: { refreshModel: true, @@ -14,6 +15,12 @@ export default Ember.Route.extend(ClusterRoute, { }, }, + activate() { + if (this.get('wizard.featureState') === 'details') { + this.get('wizard').transitionFeatureMachine('details', 'CONTINUE', this.policyType()); + } + }, + shouldReturnEmptyModel(policyType, version) { return policyType !== 'acl' && (version.get('isOSS') || !version.get('hasSentinel')); }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration.js b/ui/app/routes/vault/cluster/secrets/backend/configuration.js index e18705600d6..bb0aaabb753 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration.js +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration.js @@ -1,7 +1,16 @@ import Ember from 'ember'; export default Ember.Route.extend({ + wizard: Ember.inject.service(), model() { - return this.modelFor('vault.cluster.secrets.backend'); + let backend = this.modelFor('vault.cluster.secrets.backend'); + if (this.get('wizard.featureState') === 'list') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'CONTINUE', + backend.get('type') + ); + } + return backend; }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/create.js b/ui/app/routes/vault/cluster/secrets/backend/create.js index 8c6aed12603..159e9ac28ec 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create.js @@ -20,6 +20,7 @@ var SecretProxy = Ember.Object.extend(KeyMixin, { }); export default EditBase.extend({ + wizard: Ember.inject.service(), createModel(transition, parentKey) { const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const modelType = this.modelType(backend); @@ -27,6 +28,9 @@ export default EditBase.extend({ return this.store.createRecord(modelType, { keyType: 'ca' }); } if (modelType !== 'secret' && modelType !== 'secret-v2') { + if (this.get('wizard.featureState') === 'details' && this.get('wizard.componentState') === 'transit') { + this.get('wizard').transitionFeatureMachine('details', 'CONTINUE', 'transit'); + } return this.store.createRecord(modelType); } const key = transition.queryParams.initialKey || ''; diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.js b/ui/app/routes/vault/cluster/settings/auth/configure/section.js index 1bace6d1f03..3a5c7316fe2 100644 --- a/ui/app/routes/vault/cluster/settings/auth/configure/section.js +++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.js @@ -2,9 +2,10 @@ import Ember from 'ember'; import DS from 'ember-data'; import UnloadModelRoute from 'vault/mixins/unload-model-route'; -const { RSVP } = Ember; +const { RSVP, inject } = Ember; export default Ember.Route.extend(UnloadModelRoute, { modelPath: 'model.model', + wizard: inject.service(), modelType(backendType, section) { const MODELS = { 'aws-client': 'auth-config/aws/client', @@ -26,6 +27,11 @@ export default Ember.Route.extend(UnloadModelRoute, { const backend = this.modelFor('vault.cluster.settings.auth.configure'); const { section_name: section } = params; if (section === 'options') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'EDIT', + backend.get('type') + ); return RSVP.hash({ model: backend, section, @@ -39,6 +45,11 @@ export default Ember.Route.extend(UnloadModelRoute, { } const model = this.store.peekRecord(modelType, backend.id); if (model) { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'EDIT', + backend.get('type') + ); return RSVP.hash({ model, section, @@ -47,6 +58,11 @@ export default Ember.Route.extend(UnloadModelRoute, { return this.store .findRecord(modelType, backend.id) .then(config => { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + 'EDIT', + backend.get('type') + ); config.set('backend', backend); return RSVP.hash({ model: config, diff --git a/ui/app/routes/vault/cluster/settings/control-groups.js b/ui/app/routes/vault/cluster/settings/control-groups.js index 619fc249acd..f61cdb5fb19 100644 --- a/ui/app/routes/vault/cluster/settings/control-groups.js +++ b/ui/app/routes/vault/cluster/settings/control-groups.js @@ -13,9 +13,8 @@ export default Ember.Route.extend(UnloadModel, { model() { let type = 'control-group-config'; - return this.get('version.isOSS') - ? null - : this.store.findRecord(type, 'config').catch(e => { + return this.get('version').hasFeature('Control Groups') + ? this.store.findRecord(type, 'config').catch(e => { // if you haven't saved a config, the API 404s, so create one here to edit and return it if (e.httpStatus === 404) { return this.store.createRecord(type, { @@ -23,7 +22,8 @@ export default Ember.Route.extend(UnloadModel, { }); } throw e; - }); + }) + : null; }, actions: { diff --git a/ui/app/routes/vault/cluster/tools/tool.js b/ui/app/routes/vault/cluster/tools/tool.js index b031d0d0c9c..f315186b1bc 100644 --- a/ui/app/routes/vault/cluster/tools/tool.js +++ b/ui/app/routes/vault/cluster/tools/tool.js @@ -2,6 +2,8 @@ import Ember from 'ember'; import { toolsActions } from 'vault/helpers/tools-actions'; export default Ember.Route.extend({ + wizard: Ember.inject.service(), + beforeModel(transition) { const supportedActions = toolsActions(); const { selectedAction } = this.paramsFor(this.routeName); @@ -14,7 +16,14 @@ export default Ember.Route.extend({ actions: { didTransition() { const params = this.paramsFor(this.routeName); + if (this.get('wizard.currentMachine') === 'tools') { + this.get('wizard').transitionFeatureMachine( + this.get('wizard.featureState'), + params.selectedAction.toUpperCase() + ); + } this.controller.setProperties(params); + return true; }, }, }); diff --git a/ui/app/routes/vault/cluster/unseal.js b/ui/app/routes/vault/cluster/unseal.js index 875583f717c..55f4e3ee848 100644 --- a/ui/app/routes/vault/cluster/unseal.js +++ b/ui/app/routes/vault/cluster/unseal.js @@ -1 +1,13 @@ -export { default } from './cluster-route-base'; +import Ember from 'ember'; +import ClusterRoute from './cluster-route-base'; + +const { inject } = Ember; + +export default ClusterRoute.extend({ + wizard: inject.service(), + + activate() { + this.get('wizard').set('initEvent', 'UNSEAL'); + this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'TOUNSEAL'); + }, +}); diff --git a/ui/app/serializers/secret-engine.js b/ui/app/serializers/secret-engine.js index e10325615c4..a903214737a 100644 --- a/ui/app/serializers/secret-engine.js +++ b/ui/app/serializers/secret-engine.js @@ -1,11 +1,7 @@ -import DS from 'ember-data'; import Ember from 'ember'; -const { decamelize } = Ember.String; +import ApplicationSerializer from './application'; -export default DS.RESTSerializer.extend({ - keyForAttribute: function(attr) { - return decamelize(attr); - }, +export default ApplicationSerializer.extend({ normalizeBackend(path, backend) { let struct = {}; for (let attribute in backend) { @@ -51,11 +47,20 @@ export default DS.RESTSerializer.extend({ } } - const transformedPayload = { [primaryModelClass.modelName]: backends }; - return this._super(store, primaryModelClass, transformedPayload, id, requestType); + return this._super(store, primaryModelClass, backends, id, requestType); }, - serialize() { - return this._super(...arguments); + serialize(snapshot) { + let type = snapshot.record.get('engineType'); + let data = this._super(...arguments); + // only KV uses options + if (type !== 'kv' && type !== 'generic') { + delete data.options; + } else if (!data.options.version) { + // if options.version isn't set for some reason + // default to 2 + data.options.version = 2; + } + return data; }, }); diff --git a/ui/app/services/version.js b/ui/app/services/version.js index 487326da001..e392ebe3cac 100644 --- a/ui/app/services/version.js +++ b/ui/app/services/version.js @@ -3,13 +3,16 @@ import { task } from 'ember-concurrency'; const { Service, inject, computed } = Ember; +const hasFeatureMethod = (context, featureKey) => { + const features = context.get('features'); + if (!features) { + return false; + } + return features.includes(featureKey); +}; const hasFeature = featureKey => { return computed('features', 'features.[]', function() { - const features = this.get('features'); - if (!features) { - return false; - } - return features.includes(featureKey); + return hasFeatureMethod(this, featureKey); }); }; export default Service.extend({ @@ -33,6 +36,10 @@ export default Service.extend({ this.set('version', resp.version); }, + hasFeature(feature) { + return hasFeatureMethod(this, feature); + }, + setFeatures(resp) { if (!resp.features) { return; diff --git a/ui/app/services/wizard.js b/ui/app/services/wizard.js new file mode 100644 index 00000000000..4cddf250a97 --- /dev/null +++ b/ui/app/services/wizard.js @@ -0,0 +1,317 @@ +import Ember from 'ember'; +import { Machine } from 'xstate'; + +const { Service, inject } = Ember; + +import getStorage from 'vault/lib/token-storage'; + +import TutorialMachineConfig from 'vault/machines/tutorial-machine'; +import SecretsMachineConfig from 'vault/machines/secrets-machine'; +import PoliciesMachineConfig from 'vault/machines/policies-machine'; +import ReplicationMachineConfig from 'vault/machines/replication-machine'; +import ToolsMachineConfig from 'vault/machines/tools-machine'; +import AuthMachineConfig from 'vault/machines/auth-machine'; + +const TutorialMachine = Machine(TutorialMachineConfig); +let FeatureMachine = null; +const TUTORIAL_STATE = 'vault:ui-tutorial-state'; +const FEATURE_LIST = 'vault:ui-feature-list'; +const FEATURE_STATE = 'vault:ui-feature-state'; +const COMPLETED_FEATURES = 'vault:ui-completed-list'; +const COMPONENT_STATE = 'vault:ui-component-state'; +const RESUME_URL = 'vault:ui-tutorial-resume-url'; +const RESUME_ROUTE = 'vault:ui-tutorial-resume-route'; +const MACHINES = { + secrets: SecretsMachineConfig, + policies: PoliciesMachineConfig, + replication: ReplicationMachineConfig, + tools: ToolsMachineConfig, + authentication: AuthMachineConfig, +}; + +const DEFAULTS = { + currentState: null, + featureList: null, + featureState: null, + currentMachine: null, + tutorialComponent: null, + featureComponent: null, + stepComponent: null, + detailsComponent: null, + componentState: null, + nextFeature: null, + nextStep: null, +}; + +export default Service.extend(DEFAULTS, { + router: inject.service(), + showWhenUnauthenticated: false, + + init() { + this._super(...arguments); + this.initializeMachines(); + }, + + initializeMachines() { + if (!this.storageHasKey(TUTORIAL_STATE)) { + let state = TutorialMachine.initialState; + this.saveState('currentState', state.value); + this.saveExtState(TUTORIAL_STATE, state.value); + } + this.saveState('currentState', this.getExtState(TUTORIAL_STATE)); + if (this.storageHasKey(COMPONENT_STATE)) { + this.set('componentState', this.getExtState(COMPONENT_STATE)); + } + let stateNodes = TutorialMachine.getStateNodes(this.get('currentState')); + this.executeActions(stateNodes.reduce((acc, node) => acc.concat(node.onEntry), []), null, 'tutorial'); + if (this.storageHasKey(FEATURE_LIST)) { + this.set('featureList', this.getExtState(FEATURE_LIST)); + if (this.storageHasKey(FEATURE_STATE)) { + this.saveState('featureState', this.getExtState(FEATURE_STATE)); + } else { + if (FeatureMachine != null) { + this.saveState('featureState', FeatureMachine.initialState); + this.saveExtState(FEATURE_STATE, this.get('featureState')); + } + } + this.buildFeatureMachine(); + } + }, + + restartGuide() { + let storage = this.storage(); + // empty storage + [ + TUTORIAL_STATE, + FEATURE_LIST, + FEATURE_STATE, + COMPLETED_FEATURES, + COMPONENT_STATE, + RESUME_URL, + RESUME_ROUTE, + ].forEach(key => storage.removeItem(key)); + // reset wizard state + this.setProperties(DEFAULTS); + // restart machines from blank state + this.initializeMachines(); + // progress machine to 'active.select' + this.transitionTutorialMachine('idle', 'AUTH'); + }, + + saveState(stateType, state) { + if (state.value) { + state = state.value; + } + let stateKey = ''; + while (Ember.typeOf(state) === 'object') { + let newState = Object.keys(state); + stateKey += newState + '.'; + state = state[newState]; + } + stateKey += state; + this.set(stateType, stateKey); + }, + + transitionTutorialMachine(currentState, event, extendedState) { + if (extendedState) { + this.set('componentState', extendedState); + this.saveExtState(COMPONENT_STATE, extendedState); + } + let { actions, value } = TutorialMachine.transition(currentState, event); + this.saveState('currentState', value); + this.saveExtState(TUTORIAL_STATE, this.get('currentState')); + this.executeActions(actions, event, 'tutorial'); + }, + + transitionFeatureMachine(currentState, event, extendedState) { + if (!FeatureMachine || !this.get('currentState').includes('active')) { + return; + } + if (extendedState) { + this.set('componentState', extendedState); + this.saveExtState(COMPONENT_STATE, extendedState); + } + + let { actions, value } = FeatureMachine.transition(currentState, event, this.get('componentState')); + this.saveState('featureState', value); + this.saveExtState(FEATURE_STATE, value); + this.executeActions(actions, event, 'feature'); + // if all features were completed, the FeatureMachine gets nulled + // out and won't exist here as there is no next step + if (FeatureMachine) { + let next; + if (this.get('currentMachine') === 'secrets' && value === 'display') { + next = FeatureMachine.transition(value, 'REPEAT', this.get('componentState')); + } else { + next = FeatureMachine.transition(value, 'CONTINUE', this.get('componentState')); + } + this.saveState('nextStep', next.value); + } + }, + + saveExtState(key, value) { + this.storage().setItem(key, value); + }, + + getExtState(key) { + return this.storage().getItem(key); + }, + + storageHasKey(key) { + return Boolean(this.getExtState(key)); + }, + + executeActions(actions, event, machineType) { + let transitionURL; + let expectedRouteName; + let router = this.get('router'); + + for (let action of actions) { + let type = action; + if (action.type) { + type = action.type; + } + switch (type) { + case 'render': + this.set(`${action.level}Component`, action.component); + break; + case 'routeTransition': + expectedRouteName = action.params[0]; + transitionURL = router.urlFor(...action.params).replace(/^\/ui/, ''); + Ember.run.next(() => { + router.transitionTo(...action.params); + }); + break; + case 'saveFeatures': + this.saveFeatures(event.features); + break; + case 'completeFeature': + this.completeFeature(); + break; + case 'handleDismissed': + this.handleDismissed(); + break; + case 'handlePaused': + this.handlePaused(); + return; + case 'handleResume': + this.handleResume(); + break; + case 'showTutorialWhenAuthenticated': + this.set('showWhenUnauthenticated', false); + break; + case 'showTutorialAlways': + this.set('showWhenUnauthenticated', true); + break; + case 'continueFeature': + this.transitionFeatureMachine(this.get('featureState'), 'CONTINUE', this.get('componentState')); + break; + default: + break; + } + } + if (machineType === 'tutorial') { + return; + } + // if we're transitioning in the actions, we want that url, + // else we want the URL we land on in didTransition in the + // application route - we'll notify the application route to + // update the route + if (transitionURL) { + this.set('expectedURL', transitionURL); + this.set('expectedRouteName', expectedRouteName); + this.set('setURLAfterTransition', false); + } else { + this.set('setURLAfterTransition', true); + } + }, + + handlePaused() { + let expected = this.get('expectedURL'); + if (expected) { + this.saveExtState(RESUME_URL, this.get('expectedURL')); + this.saveExtState(RESUME_ROUTE, this.get('expectedRouteName')); + } + }, + + handleResume() { + let resumeURL = this.storage().getItem(RESUME_URL); + if (!resumeURL) { + return; + } + this.get('router').transitionTo(resumeURL).followRedirects().then(() => { + this.set('expectedRouteName', this.storage().getItem(RESUME_ROUTE)); + this.set('expectedURL', resumeURL); + this.initializeMachines(); + this.storage().removeItem(RESUME_URL); + }); + }, + + handleDismissed() { + this.storage().removeItem(FEATURE_STATE); + this.storage().removeItem(FEATURE_LIST); + this.storage().removeItem(COMPONENT_STATE); + }, + + saveFeatures(features) { + this.set('featureList', features); + this.saveExtState(FEATURE_LIST, this.get('featureList')); + this.buildFeatureMachine(); + }, + + buildFeatureMachine() { + if (this.get('featureList') === null) { + return; + } + this.startFeature(); + if (this.storageHasKey(FEATURE_STATE)) { + this.saveState('featureState', this.getExtState(FEATURE_STATE)); + } + this.saveExtState(FEATURE_STATE, this.get('featureState')); + let nextFeature = + this.get('featureList').length > 1 ? this.get('featureList').objectAt(1).capitalize() : 'Finish'; + this.set('nextFeature', nextFeature); + let next; + if (this.get('currentMachine') === 'secrets' && this.get('featureState') === 'display') { + next = FeatureMachine.transition(this.get('featureState'), 'REPEAT', this.get('componentState')); + } else { + next = FeatureMachine.transition(this.get('featureState'), 'CONTINUE', this.get('componentState')); + } + this.saveState('nextStep', next.value); + let stateNodes = FeatureMachine.getStateNodes(this.get('featureState')); + this.executeActions(stateNodes.reduce((acc, node) => acc.concat(node.onEntry), []), null, 'feature'); + }, + + startFeature() { + const FeatureMachineConfig = MACHINES[this.get('featureList').objectAt(0)]; + FeatureMachine = Machine(FeatureMachineConfig); + this.set('currentMachine', this.get('featureList').objectAt(0)); + this.saveState('featureState', FeatureMachine.initialState); + }, + + completeFeature() { + let features = this.get('featureList'); + let done = features.shift(); + if (!this.getExtState(COMPLETED_FEATURES)) { + let completed = []; + completed.push(done); + this.saveExtState(COMPLETED_FEATURES, completed); + } else { + this.saveExtState(COMPLETED_FEATURES, this.getExtState(COMPLETED_FEATURES).toArray().addObject(done)); + } + + this.saveExtState(FEATURE_LIST, features.length ? features : null); + this.storage().removeItem(FEATURE_STATE); + if (features.length > 0) { + this.buildFeatureMachine(); + } else { + this.storage().removeItem(FEATURE_LIST); + FeatureMachine = null; + this.transitionTutorialMachine(this.get('currentState'), 'DONE'); + } + }, + + storage() { + return getStorage(); + }, +}); diff --git a/ui/app/styles/components/box-radio.scss b/ui/app/styles/components/box-radio.scss new file mode 100644 index 00000000000..41079d7bf6d --- /dev/null +++ b/ui/app/styles/components/box-radio.scss @@ -0,0 +1,61 @@ +.box-radio-container { + display: flex; + flex-wrap: wrap; +} +.title.box-radio-header { + font-size: $size-6; + color: $grey; + margin: $size-7 0 0 0; +} +.box-radio { + box-sizing: border-box; + flex-basis: 7rem; + width: 7rem; + height: 7.5rem; + padding: $size-10 $size-6 $size-10; + flex-direction: column; + justify-content: space-between; + align-items: center; + display: flex; + border-radius: $radius; + box-shadow: $box-shadow; + text-align: center; + color: $grey; + font-weight: $font-weight-semibold; + line-height: 1; + margin: $size-6 $size-3 $size-6 0; + font-size: 12px; + transition: box-shadow ease-in-out $speed; + will-change: box-shadow; + + &.is-selected { + box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle; + } + + input[type=radio].radio { + position: absolute; + z-index: 1; + opacity: 0; + } + + input[type=radio].radio + label { + border: 1px solid $grey-light; + border-radius: 50%; + cursor: pointer; + display: block; + margin: 1rem auto 0; + height: 1rem; + width: 1rem; + flex-shrink: 0; + flex-grow: 0; + } + + input[type=radio].radio:checked + label { + background: $blue; + border: 1px solid $blue; + box-shadow: inset 0 0 0 0.15rem $white; + } + input[type=radio].radio:focus + label { + box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white; + } +} diff --git a/ui/app/styles/components/doc-link.scss b/ui/app/styles/components/doc-link.scss new file mode 100644 index 00000000000..8d97c0d2c93 --- /dev/null +++ b/ui/app/styles/components/doc-link.scss @@ -0,0 +1,5 @@ +.doc-link { + color: $link; + text-decoration: none; + font-weight: $font-weight-semibold; +} diff --git a/ui/app/styles/components/features-selection.scss b/ui/app/styles/components/features-selection.scss new file mode 100644 index 00000000000..dd4bb3195e2 --- /dev/null +++ b/ui/app/styles/components/features-selection.scss @@ -0,0 +1,44 @@ +.feature-header { + font-size: $size-6; + font-weight: $font-weight-semibold; + color: $grey; +} + +.feature-box { + box-shadow: $box-shadow; + border-radius: $radius; + padding: $size-8; + margin: $size-8 0; + + &.is-active { + box-shadow: 0 0 0 1px $grey-light; + } +} + +.feature-box label { + font-weight: $font-weight-semibold; + padding-left: $size-10; + + &::before { + top: 3px; + } + + &::after { + top: 5px; + } +} + +.feature-steps { + font-size: $size-8; + color: $grey; + line-height: 1.5; + margin-left: $size-3; + margin-top: $size-10; + + li::before { + // bullet + content: '\2022'; + position: relative; + right: $size-11; + } +} diff --git a/ui/app/styles/components/page-header.scss b/ui/app/styles/components/page-header.scss index 5557130a201..13d7977a366 100644 --- a/ui/app/styles/components/page-header.scss +++ b/ui/app/styles/components/page-header.scss @@ -21,4 +21,8 @@ .breadcrumb + .level .title { margin-top: $size-4; } + .title .icon { + height: auto; + width: auto; + } } diff --git a/ui/app/styles/components/ui-wizard.scss b/ui/app/styles/components/ui-wizard.scss new file mode 100644 index 00000000000..8ab26f25605 --- /dev/null +++ b/ui/app/styles/components/ui-wizard.scss @@ -0,0 +1,144 @@ +.ui-wizard-container { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.ui-wizard-container .app-content { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.ui-wizard-container .app-content.wizard-open { + padding-right: 324px; + + @include until($tablet) { + padding-right: 0; + padding-bottom: 50vh; + } +} + +.ui-wizard { + z-index: 300; + padding: $size-5; + width: 300px; + background: $white; + box-shadow: $box-shadow, $box-shadow-highest; + position: fixed; + right: $size-8; + bottom: $size-8; + top: calc(3.5rem + #{$size-8}); + overflow: auto; + + p { + line-height: 1.2; + } + + .dismiss-collapsed { + position: absolute; + top: $size-8; + right: $size-8; + color: $grey; + z-index: 30; + } + + @include until($tablet) { + box-shadow: $box-shadow, 0 0 20px rgba($black, 0.24); + bottom: 0; + left: 0; + right: 0; + top: 50%; + width: auto; + } + + .doc-link { + margin-top: $size-5; + display: block; + } + + pre code { + background: $ui-gray-050; + margin: $size-8 0; + } +} + +.wizard-header { + margin-bottom: $size-5; + position: relative; + + .icon { + margin-right: $size-11; + vertical-align: -0.33rem; + } +} + +.wizard-dismiss-menu { + position: absolute; + right: $size-6; + top: $size-6; + z-index: 10; +} + +.ui-wizard.collapsed { + color: $white; + background: $black; + bottom: auto; + box-shadow: $box-shadow-middle; + height: auto; + min-height: 0; + padding-bottom: $size-11; + position: fixed; + right: $size-8; + top: calc(3.5rem + #{$size-8}); + + @include until($tablet) { + box-shadow: $box-shadow, 0 0 20px rgba($black, 0.24); + bottom: 0; + left: 0; + right: 0; + top: auto; + width: auto; + } + + .title { + color: $white; + } + + .wizard-header { + margin-bottom: $size-10; + } +} + +.wizard-divider-box { + background: none; + box-shadow: none; + margin: $size-8 0 0; + padding: 0 $size-8; + border-top: solid 1px $white; + border-image: $dark-vault-gradient 1; + button { + font-size: $size-7; + font-weight: $font-weight-semibold; + } +} + +.wizard-section .title .icon { + height: auto; + margin-right: $size-11; + width: auto; +} + +.wizard-section:last-of-type { + margin-bottom: $size-5; +} + +.wizard-section button:not(:last-of-type) { + margin-bottom: $size-10; +} + +.wizard-details { + padding-top: $size-4; + margin-top: $size-4; + border-top: 1px solid $grey-light; +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 0b8b08739b8..eb5bfa831bf 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -44,11 +44,14 @@ @import "./components/auth-form"; @import "./components/b64-toggle"; @import "./components/box-label"; +@import "./components/box-radio"; @import "./components/codemirror"; @import "./components/confirm"; @import "./components/console-ui-panel"; @import "./components/control-group"; +@import "./components/doc-link"; @import "./components/env-banner"; +@import "./components/features-selection"; @import "./components/form-section"; @import "./components/global-flash"; @import "./components/hover-copy-button"; @@ -77,4 +80,5 @@ @import "./components/tool-tip"; @import "./components/unseal-warning"; @import "./components/upgrade-overlay"; +@import "./components/ui-wizard"; @import "./components/vault-loading"; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 69be8d52d41..4a823f49268 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -184,6 +184,7 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); &, &:first-child:last-child { margin-left: -$size-10; + margin-right: $size-11; } } } @@ -214,3 +215,16 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); width: auto; margin: 0 !important; } + +.button.next-feature-step { + width: 100%; + text-align: left; + background: $white; + color: $blue; + box-shadow: none; + display: block; + border: 1px solid $grey-light; + border-radius: $radius; + height: auto; + padding: $size-8; +} diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index e9210ec3aaf..57fcacb68da 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -184,7 +184,8 @@ label { } } -.select:not(.is-multiple)::after { +.select:not(.is-multiple)::after, +.select:not(.is-multiple)::before { border-color: $black; border-width: 2px; margin-top: 0; @@ -192,7 +193,6 @@ label { } .select:not(.is-multiple)::before { - @extend .select:not(.is-multiple)::after; transform: translateY(-75%) rotate(135deg); z-index: 5; } diff --git a/ui/app/styles/core/generic.scss b/ui/app/styles/core/generic.scss index 1325a1fc82b..21bab9841b6 100644 --- a/ui/app/styles/core/generic.scss +++ b/ui/app/styles/core/generic.scss @@ -46,3 +46,7 @@ input::-webkit-inner-spin-button { -ms-user-select: text; /* IE 10+ */ user-select: text; } + +.link-plain { + text-decoration: none; +} diff --git a/ui/app/styles/core/gradients.scss b/ui/app/styles/core/gradients.scss index 7cd9111dfdd..dd137335ee2 100644 --- a/ui/app/styles/core/gradients.scss +++ b/ui/app/styles/core/gradients.scss @@ -1,3 +1,4 @@ +$dark-vault-gradient: linear-gradient(to right, $vault-gray-dark, $vault-gray); .has-dark-vault-gradient { - background: linear-gradient(to right, $vault-gray-dark, $vault-gray); + background: $dark-vault-gradient; } diff --git a/ui/app/templates/components/auth-info.hbs b/ui/app/templates/components/auth-info.hbs index d69b8e54ada..726754e8670 100644 --- a/ui/app/templates/components/auth-info.hbs +++ b/ui/app/templates/components/auth-info.hbs @@ -46,6 +46,11 @@ {{/if}} {{/if}} +
+ The AD secrets engine rotates AD passwords dynamically, and is designed for + a high-load environment where many instances may be accessing a shared password simultaneously. +
++ The approle auth method allows machines or apps to authenticate with Vault-defined roles. The open design of AppRole enables a varied set of workflows and configurations to handle large numbers of apps. This auth method is oriented to automated workflows (machines and services), and is less useful for human operators. +
++ Fantastic! Now you're ready to use your new {{mountName}} auth method! +
++ You can update your new auth method configuration here. Click the "View method" link to see its details. +
++ Great! Now you can customize this method with a name and description that makes sense for your team, and fill out any options that are specific to this method. +
++ Controlling who can see your secrets is important. Let's set up a an authentication method for you and your team to use. Don't worry, you can add more methods later. Choose an authentication method to get started. +
++ Awesome! Now you can see your new auth method in the list. Click the ellipsis menu for your method and then click "View Configuration" to see its details. +
++ The AWS secrets engine generates AWS access credentials dynamically based on IAM policies. This generally makes working with AWS IAM easier, since it does not involve clicking in the web UI. Additionally, the process is codified and mapped to internal auth methods (such as LDAP). The AWS IAM credentials are time-based and are automatically revoked when the Vault lease expires. +
++ The AWS auth method provides an automated mechanism to retrieve a Vault token for AWS EC2 instances and IAM principals. Unlike most Vault auth methods, this method does not require manual first-deploying, or provisioning security-sensitive credentials (tokens, username/password, client certificates, etc), by operators under many circumstances. +
++ The Azure auth method allows authentication against Vault using Azure Active Directory credentials. +
++ The TLS Certificates auth method allows authentication using SSL/TLS client certificates which are either signed by a CA or self-signed. CA certificates are associated with a role. +
++ The cubbyhole secrets engine is used to store arbitrary secrets within the configured physical storage for Vault namespaced to a token. In cubbyhole, paths are scoped per token. No token can access another token's cubbyhole. When the token expires, its cubbyhole is destroyed. +
++ The Consul secrets engine generates Consul API tokens dynamically based on Consul ACL policies. +
++ The database secrets engine generates database credentials dynamically based on configured roles. +
+You did it! You now have access to your Vault and can start entering your data. We can help you get started with any of the options below
+ {{#if (or (has-feature "Performance Replication") (has-feature "DR Replication")) }} + {{/if}} ++ The Google Cloud Vault secrets engine dynamically generates Google Cloud service account keys and OAuth tokens based on IAM policies. This enables users to gain access to Google Cloud resources without needing to create or manage a dedicated service account. +
++ The GCP auth method allows authentication against Vault using Google credentials. +
++ The Github auth method can be used to authenticate with Vault using a GitHub personal access token. +
++ Vault is unsealed, but we still need to authenticate using the Initial + Root Token that was generated. We recommend setting up an Authentication + Method such as Username & Password for regular use, and only using a root + token for initial setup or for emergencies. +
+Now that Vault is initialized, you'll want to save your root token and + master key portions in a safe place. Distribute your keys to responsible + people on your team. If these keys are lost, you may not be able to access + your data again. Keep them safe!
++ This is the very first step of setting + a Vault server. Vault comes with an important + security feature called "seal", which lets you + shut down and secure your Vault installation if + there is a security breach. This is the default + state of Vault, so it is currently sealed since + it was just installed. To unseal the vault, you will + need to provide the key(s) that you generate here. +
++ Now we will provide the {{pluralize componentState.threshold 'key'}} that + you copied or downloaded to unseal the vault so that we can get started + using it. You'll need {{pluralize componentState.threshold 'key'}} total, + and {{#with (pluralize componentState.progress 'key' without-count=true) as |word|}} + {{if (eq word 'key') + (concat componentState.progress " " word " has ") + (concat componentState.progress " " word " have ") + }} + {{/with}} already been provided. + Please provide + {{pluralize (dec componentState.progress componentState.threshold) 'more key'}} to unseal. +
++ The Kubernetes auth method can be used to authenticate with Vault using a Kubernetes Service Account Token. This method of authentication makes it easy to introduce a Vault token into a Kubernetes Pod. +
++ The kv secrets engine is used to store arbitrary secrets within the configured physical storage for Vault. This backend can be run in one of two modes. It can be a generic Key-Value store that stores one value for a key. Versioning can be enabled and a configurable number of versions for each key will be stored. +
++ The LDAP auth method allows authentication using an existing LDAP server and user/password credentials. This allows Vault to be integrated into environments using LDAP without duplicating the user/pass configuration in multiple places. +
++ The Nomad secret backend for Vault generates Nomad API tokens dynamically based on pre-existing Nomad ACL policies. +
++ The Okta auth method allows authentication using Okta and user/password credentials. This allows Vault to be integrated into environments using Okta. +
++ The PKI secrets engine generates dynamic X.509 certificates. With this secrets engine, services can get certificates without going through the usual manual process of generating a private key and CSR, submitting to a CA, and waiting for a verification and signing process to complete. +
+
+ Let's use "my-new-policy" for your policy name. Copy the policy below to try it out:
+
path "secret/foo" {
+ capabilities = ["read"]
+}
+ + You can delete your test policy by clicking the "..." icon to the right of the policy name. Click on "Delete" to remove it. +
++ Good job! Here you can see your new policy. If you'd like to edit it, you'd just click the "Edit" toggle. Let's go back to the list of policies by clicking on "ACL Policies" in the sidebar. +
++ Policies in Vault are a way for you to control what data can be accessed, including things like creating new secrets, listing users, or even entire Vault features. To get started with something simple, click on "Create ACL policy" +
++ Good! Now you're ready to go writing your own policies. We only explored ACL policies, but there are two other types of policies available to Enterprise customers that might be what you need. RGP (Role Governing Policies) are policies tied to particular tokens, entities, or groups. EGP (Endpoint Governing Policies) are tied to specific paths instead of tokens. +
++ The RabbitMQ secrets engine generates user credentials dynamically based on configured permissions and virtual hosts. This means that services that need to access a virtual host no longer need to hardcode credentials. +
++ The RADIUS auth method allows users to authenticate with Vault using an existing RADIUS server that accepts the PAP authentication scheme. +
++ Here you can see the details about your new replication cluster, manage or disable replication, and handle secondary clusters. You can also get a quick status by hovering over the "Replication" link at the top. +
++ Vault has two kinds of replication, each for a different purpose. Do you want to keep a backup of your data, or are you more interested in speed of access? Choose the one that is right for your needs. +
++ A cluster is set as either a primary or secondary. The primary cluster is authoritative, and is the only cluster allowed to perform actions that write to the underlying data storage, such as modifying policies or secrets. Secondary clusters can service all other operations and forward any writes to the primary cluster. +
++ Enter details and generate your credential. +
++ {{#if needsEncryption}} + The Transit Secrets Engine uses encryption keys to provide "encryption as a service". Click on "Create Encryption Key" at the top to create one. + {{else}} + Now that we've mounted the {{secretType}} Secrets Engine, let's add a {{nextStep}}. Click on the link in the page header. + {{/if}} +
++ With our new role, we can generate a credential that has the same permissions as that role. Click on "Generate Credentials" links at the top of the page. +
++ {{#if actionText}} + Here is your generated credential. As you can see, we can only show the credential once, so you'll want to be sure to save it. If you need another credential in the future, just come back and generate a new one. + {{else}} + Well done! + {{/if}} + You're now ready to start using your new {{mountName}} secrets engine. +
++ Here you can see all the details of your new engine. This can be useful to get information for things like TTL or Seal Wrap settings. +
++ Good choice! Now you can customize your engine with a name and description that makes sense for your team, as well as options for replication and caching. +
++ Enter the details about your encryption key and save it. +
++ Vault is all about managing secrets, so let's set up your first secrets engine. You can use a static engine to store your secrets locally in Vault, or connect to a cloud backend with one of the dynamic engines. +
++ This engine isn't fully supported in the Vault UI yet, but you can view and edit the configuration and use the Vault Browser CLI to interact with the engine just like you would on the command-ine. Find the engine in the list and click on "View Configuration" in the menu on the right. +
++ A role grants permissions that specify what an identity can and cannot do. A role is typically shared among many users who are then granted credentials with that are granted the policy permissions. Enter your role details and save it. +
++ Enter the details of your secret and save it. +
++ The Vault SSH secrets engine provides secure authentication and authorization for access to machines via the SSH protocol. The Vault SSH secrets engine helps manage access to machine infrastructure, providing several ways to issue SSH credentials. +
++ Good job! You can see some basic information about your wrapped data, including the expiration time. Next up, we'll take the token you still have in your clipboard and rewrap it to keep it active and extend that expiration time. Click on "Rewrap" in the sidebar. +
++ Lookup lets you see information about your token without unwrapping it or changing it. Paste your token here and click "Lookup". If you find that your data didn't copy for some reason, you can always go back and do it again. +
++ Paste your token into the input and click "Rewrap Token" to transform your token into a new one. Don't worry though, it will still have the same data. +
++ It's a subtle transformation, but your old token has been revoked and this new one has taken its place. Copy this one and then click on "Unwrap" in the sidebar to make sure the data is still in there. +
++ We saved this step for the end because unwrapping the token will revoke it, so we can only do this once. Paste your token into the input and click "Unwrap Data" +
++ Here you can see that your data survived intact. These tools are mostly handy for applications to use, but if you ever do need to wrap data or handle the wrapping token, now you know how. +
++ Vault provides several ways to create or wrap data, and manage it from there. Here you can wrap a token (or anything you like) in JSON format. Give it a try. +
++ Your data is now encrypted. You can recover the data using the token on this page, but be careful because if you lose the token you won't be able to retrieve your data! We will use this token for the next few steps, so copy the token and then click on "Lookup" in the sidebar. +
++ The TOTP secrets engine generates time-based credentials according to the TOTP standard. +
++ The transit secrets engine handles cryptographic functions on data in-transit. Vault doesn't store the data sent to the secrets engine. It can also be viewed as "cryptography as a service" or "encryption as a service". +
++ We hope you enjoyed using Vault. You can get back to the guide in the user menu in the upper right. +
++ Something went wrong and you can't complete this step. +
+Want a tour? Our helpful guide will introduce you to the Vault Web UI.
+Feel free to explore Vault. Click below to get back to the guide or close this window.
++ The Username & Password auth method allows users to authenticate with Vault using a username and password combination. +
+