diff --git a/CHANGELOG.md b/CHANGELOG.md index ba260e3d47..c1afcaa7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,22 @@ IMPROVEMENTS: * auth/token: New tokens are salted using SHA2-256 HMAC instead of SHA1 hash * secret/database: Allow Cassandra user to be non-superuser so long as it has role creation permissions [GH-5402] + * secret/radius: Allow setting the NAS Identifier value in the generated + packet [GH-5465] + * ui: Allow viewing and updating Vault license via the UI + * ui: Onboarding will now display your progress through the chosen tutorials BUG FIXES: * agent: Fix potential hang during agent shutdown [GH-5026] * core: Fix generate-root operations requiring empty `otp` to be provided instead of an empty body [GH-5495] + * identity: Remove lookup check during alias removal from entity [GH-5524] * secret/pki: Fix regression in 0.11.2+ causing the NotBefore value of generated certificates to be set to the Unix epoch if the role value was not set, instead of using the default of 30 seconds [GH-5481] + * storage/mysql: Use `varbinary` instead of `varchar` when creating HA tables + [GH-5529] ## 0.11.3 (October 8th, 2018) diff --git a/builtin/credential/radius/path_config.go b/builtin/credential/radius/path_config.go index 4a3f72e33b..be31877d71 100644 --- a/builtin/credential/radius/path_config.go +++ b/builtin/credential/radius/path_config.go @@ -46,6 +46,11 @@ func pathConfig(b *backend) *framework.Path { Default: 10, Description: "RADIUS NAS port field (default: 10)", }, + "nas_identifier": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "", + Description: "RADIUS NAS Identifier field (optional)", + }, }, ExistenceCheck: b.configExistenceCheck, @@ -110,6 +115,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, d *f "dial_timeout": cfg.DialTimeout, "read_timeout": cfg.ReadTimeout, "nas_port": cfg.NasPort, + "nas_identifier": cfg.NasIdentifier, }, } return resp, nil @@ -190,6 +196,13 @@ func (b *backend) pathConfigCreateUpdate(ctx context.Context, req *logical.Reque cfg.NasPort = d.Get("nas_port").(int) } + nasIdentifier, ok := d.GetOk("nas_identifier") + if ok { + cfg.NasIdentifier = nasIdentifier.(string) + } else if req.Operation == logical.CreateOperation { + cfg.NasIdentifier = d.Get("nas_identifier").(string) + } + entry, err := logical.StorageEntryJSON("config", cfg) if err != nil { return nil, err @@ -209,6 +222,7 @@ type ConfigEntry struct { DialTimeout int `json:"dial_timeout" structs:"dial_timeout" mapstructure:"dial_timeout"` ReadTimeout int `json:"read_timeout" structs:"read_timeout" mapstructure:"read_timeout"` NasPort int `json:"nas_port" structs:"nas_port" mapstructure:"nas_port"` + NasIdentifier string `json:"nas_identifier" structs:"nas_identifier" mapstructure:"nas_identifier"` } const pathConfigHelpSyn = ` diff --git a/builtin/credential/radius/path_login.go b/builtin/credential/radius/path_login.go index ef0c185d88..2cf2fa2f61 100644 --- a/builtin/credential/radius/path_login.go +++ b/builtin/credential/radius/path_login.go @@ -144,6 +144,9 @@ func (b *backend) RadiusLogin(ctx context.Context, req *logical.Request, usernam packet := radius.New(radius.CodeAccessRequest, []byte(cfg.Secret)) UserName_SetString(packet, username) UserPassword_SetString(packet, password) + if cfg.NasIdentifier != "" { + NASIdentifier_AddString(packet, cfg.NasIdentifier) + } packet.Add(5, radius.NewInteger(uint32(cfg.NasPort))) client := radius.Client{ diff --git a/physical/mysql/mysql.go b/physical/mysql/mysql.go index db078af5d2..de63d1307b 100644 --- a/physical/mysql/mysql.go +++ b/physical/mysql/mysql.go @@ -150,7 +150,7 @@ func NewMySQLBackend(conf map[string]string, logger log.Logger) (physical.Backen // Create the required table if it doesn't exists. if !lockTableExist { create_query := "CREATE TABLE IF NOT EXISTS " + dbLockTable + - " (node_job varchar(512), current_leader varchar(512), PRIMARY KEY (node_job))" + " (node_job varbinary(512), current_leader varbinary(512), PRIMARY KEY (node_job))" if _, err := db.Exec(create_query); err != nil { return nil, errwrap.Wrapf("failed to create mysql table: {{err}}", err) } diff --git a/ui/app/adapters/capabilities.js b/ui/app/adapters/capabilities.js index f966cb5441..ad04c10368 100644 --- a/ui/app/adapters/capabilities.js +++ b/ui/app/adapters/capabilities.js @@ -18,6 +18,9 @@ export default ApplicationAdapter.extend({ queryRecord(store, type, query) { const { id } = query; + if (!id) { + return; + } return this.findRecord(store, type, id).then(resp => { resp.path = id; return resp; diff --git a/ui/app/adapters/secret-v2-version.js b/ui/app/adapters/secret-v2-version.js new file mode 100644 index 0000000000..0d896f99a3 --- /dev/null +++ b/ui/app/adapters/secret-v2-version.js @@ -0,0 +1,72 @@ +/* eslint-disable */ +import { isEmpty } from '@ember/utils'; +import { get } from '@ember/object'; +import ApplicationAdapter from './application'; +import DS from 'ember-data'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + _url(backend, id, infix = 'data') { + let url = `${this.buildURL()}/${backend}/${infix}/`; + if (!isEmpty(id)) { + url = url + id; + } + return url; + }, + + urlForFindRecord(id) { + let [backend, path, version] = JSON.parse(id); + return this._url(backend, path) + `?version=${version}`; + }, + + findRecord() { + return this._super(...arguments).catch(errorOrModel => { + // if it's a real 404, this will be an error, if not + // it will be the body of a deleted / destroyed version + if (errorOrModel instanceof DS.AdapterError) { + throw errorOrModel; + } + return errorOrModel; + }); + }, + + urlForCreateRecord(modelName, snapshot) { + let backend = snapshot.belongsTo('secret').belongsTo('engine').id; + let path = snapshot.attr('path'); + return this._url(backend, path); + }, + + createRecord(store, modelName, snapshot) { + let backend = snapshot.belongsTo('secret').belongsTo('engine').id; + let path = snapshot.attr('path'); + return this._super(...arguments).then(resp => { + resp.id = JSON.stringify([backend, path, resp.version]); + return resp; + }); + }, + + urlForUpdateRecord(id) { + let [backend, path] = JSON.parse(id); + return this._url(backend, path); + }, + + v2DeleteOperation(store, id, deleteType = 'delete') { + let [backend, path, version] = JSON.parse(id); + + // deleteType should be 'delete', 'destroy', 'undelete' + return this.ajax(this._url(backend, path, deleteType), 'POST', { data: { versions: [version] } }).then( + () => { + let model = store.peekRecord('secret-v2-version', id); + return model && model.reload(); + } + ); + }, + + handleResponse(status, headers, payload, requestData) { + // the body of the 404 will have some relevant information + if (status === 404 && get(payload, 'data.metadata')) { + return this._super(200, headers, payload, requestData); + } + return this._super(...arguments); + }, +}); diff --git a/ui/app/adapters/secret-v2.js b/ui/app/adapters/secret-v2.js index d76fb2438c..14310c070d 100644 --- a/ui/app/adapters/secret-v2.js +++ b/ui/app/adapters/secret-v2.js @@ -1,34 +1,55 @@ +/* eslint-disable */ import { isEmpty } from '@ember/utils'; -import SecretAdapter from './secret'; +import ApplicationAdapter from './application'; -export default SecretAdapter.extend({ - createOrUpdate(store, type, snapshot) { - const serializer = store.serializerFor(type.modelName); - const data = serializer.serialize(snapshot); - const { id } = snapshot; - - return this.ajax(this.urlForSecret(snapshot.attr('backend'), id), 'POST', { - data: { data }, - }); - }, - - urlForSecret(backend, id, infix = 'data') { - let url = `${this.buildURL()}/${backend}/${infix}/`; +export default ApplicationAdapter.extend({ + namespace: 'v1', + _url(backend, id) { + let url = `${this.buildURL()}/${backend}/metadata/`; if (!isEmpty(id)) { url = url + id; } return url; }, - fetchByQuery(query, methodCall) { - let { id, backend } = query; - let args = [backend, id]; - if (methodCall === 'query') { - args.push('metadata'); - } - return this.ajax(this.urlForSecret(...args), 'GET', this.optionsForQuery(id, methodCall)).then(resp => { + // we override query here because the query object has a bunch of client-side + // concerns and we only want to send "list" to the server + query(store, type, query) { + let { backend, id } = query; + return this.ajax(this._url(backend, id), 'GET', { data: { list: true } }).then(resp => { resp.id = id; + resp.backend = backend; return resp; }); }, + + urlForQueryRecord(query) { + let { id, backend } = query; + return this._url(backend, id); + }, + + queryRecord(store, type, query) { + let { backend, id } = query; + return this.ajax(this._url(backend, id), 'GET').then(resp => { + resp.id = id; + resp.backend = backend; + return resp; + }); + }, + + detailURL(snapshot) { + let backend = snapshot.belongsTo('engine', { id: true }) || snapshot.attr('engineId'); + let { id } = snapshot; + return this._url(backend, id); + }, + + urlForUpdateRecord(store, type, snapshot) { + return this.detailURL(snapshot); + }, + urlForCreateRecord(modelName, snapshot) { + return this.detailURL(snapshot); + }, + urlForDeleteRecord(store, type, snapshot) { + return this.detailURL(snapshot); + }, }); diff --git a/ui/app/adapters/secret.js b/ui/app/adapters/secret.js index 30721e95fe..30f2e5f3f6 100644 --- a/ui/app/adapters/secret.js +++ b/ui/app/adapters/secret.js @@ -47,6 +47,7 @@ export default ApplicationAdapter.extend({ const { id, backend } = query; return this.ajax(this.urlForSecret(backend, id), 'GET', this.optionsForQuery(id, action)).then(resp => { resp.id = id; + resp.backend = backend; return resp; }); }, diff --git a/ui/app/components/block-empty.js b/ui/app/components/block-empty.js new file mode 100644 index 0000000000..96167992d7 --- /dev/null +++ b/ui/app/components/block-empty.js @@ -0,0 +1,2 @@ +import OuterHTML from './outer-html'; +export default OuterHTML.extend(); diff --git a/ui/app/components/block-error.js b/ui/app/components/block-error.js new file mode 100644 index 0000000000..96167992d7 --- /dev/null +++ b/ui/app/components/block-error.js @@ -0,0 +1,2 @@ +import OuterHTML from './outer-html'; +export default OuterHTML.extend(); diff --git a/ui/app/components/i-con.js b/ui/app/components/i-con.js index e57f1fe447..78d182f10b 100644 --- a/ui/app/components/i-con.js +++ b/ui/app/components/i-con.js @@ -22,6 +22,9 @@ const GLYPHS_WITH_SVG_TAG = [ 'control-lock', 'edition-enterprise', 'edition-oss', + 'check-plain', + 'check-circle-fill', + 'cancel-square-outline', ]; export default Component.extend({ diff --git a/ui/app/components/namespace-picker.js b/ui/app/components/namespace-picker.js index f06ffb7dda..5cc429e446 100644 --- a/ui/app/components/namespace-picker.js +++ b/ui/app/components/namespace-picker.js @@ -39,7 +39,8 @@ export default Component.extend({ fetchListCapability: task(function*() { try { if (this.listCapability) { - this.listCapability.unloadRecord(); + yield this.listCapability.reload(); + return; } let capability = yield this.store.findRecord('capabilities', 'sys/namespaces/'); this.set('listCapability', capability); diff --git a/ui/app/components/navigate-input.js b/ui/app/components/navigate-input.js index f642b6d1b1..300a9afebd 100644 --- a/ui/app/components/navigate-input.js +++ b/ui/app/components/navigate-input.js @@ -163,8 +163,7 @@ export default Component.extend(FocusOnInsertMixin, { }, actions: { - handleInput: function(event) { - var filter = event.target.value; + handleInput: function(filter) { this.get('filterDidChange')(filter); debounce(this, 'filterUpdated', filter, 200); }, @@ -173,14 +172,15 @@ export default Component.extend(FocusOnInsertMixin, { this.get('filterFocusDidChange')(isFocused); }, - handleKeyPress: function(val, event) { + handleKeyPress: function(event) { if (event.keyCode === keys.TAB) { this.onTab(event); } }, - handleKeyUp: function(val, event) { + handleKeyUp: function(event) { var keyCode = event.keyCode; + let val = event.target.value; if (keyCode === keys.ENTER) { this.onEnter(val); } diff --git a/ui/app/components/secret-edit.js b/ui/app/components/secret-edit.js index cfa3b5d653..b83d451ee6 100644 --- a/ui/app/components/secret-edit.js +++ b/ui/app/components/secret-edit.js @@ -1,12 +1,14 @@ import { or } from '@ember/object/computed'; import { isBlank, isNone } from '@ember/utils'; -import $ from 'jquery'; import { inject as service } from '@ember/service'; import Component from '@ember/component'; -import { computed, get } from '@ember/object'; +import { computed, set } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { task, waitForEvent } from 'ember-concurrency'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; import keys from 'vault/lib/keycodes'; import KVObject from 'vault/lib/kv-object'; +import { maybeQueryRecord } from 'vault/macros/maybe-query-record'; const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; @@ -15,9 +17,11 @@ const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; export default Component.extend(FocusOnInsertMixin, { wizard: service(), router: service(), + store: service(), // a key model key: null, + model: null, // a value to pre-fill the key input - this is populated by the corresponding // 'initialKey' queryParam @@ -44,10 +48,15 @@ export default Component.extend(FocusOnInsertMixin, { codemirrorString: null, hasLintError: false, + isV2: false, init() { this._super(...arguments); - const secrets = this.get('key.secretData'); + let secrets = this.model.secretData; + if (!secrets && this.model.selectedVersion) { + this.set('isV2', true); + secrets = this.model.belongsTo('selectedVersion').value().secretData; + } const data = KVObject.create({ content: [] }).fromJSON(secrets); this.set('secretData', data); this.set('codemirrorString', data.toJSONString()); @@ -55,82 +64,152 @@ export default 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.wizard.featureState === 'details' && this.mode === 'create') { + let engine = this.model.backend.includes('kv') ? 'kv' : this.model.backend; + this.wizard.transitionFeatureMachine('details', 'CONTINUE', engine); } - if (this.get('mode') === 'edit') { + if (this.mode === 'edit') { this.send('addRow'); } }, - didInsertElement() { - this._super(...arguments); - $(document).on('keyup.keyEdit', this.onEscape.bind(this)); - }, - - willDestroyElement() { - this._super(...arguments); - const key = this.get('key'); - if (get(key, 'isError') && !key.isDestroyed) { - key.rollbackAttributes(); + waitForKeyUp: task(function*() { + while (true) { + let event = yield waitForEvent(document.body, 'keyup'); + this.onEscape(event); } - $(document).off('keyup.keyEdit'); - }, + }) + .on('didInsertElement') + .cancelOn('willDestroyElement'), partialName: computed('mode', function() { - return `partials/secret-form-${this.get('mode')}`; + return `partials/secret-form-${this.mode}`; }), - showPrefix: or('key.initialParentKey', 'key.parentKey'), + updatePath: maybeQueryRecord( + 'capabilities', + context => { + if (context.mode === 'create') { + return; + } + let backend = context.isV2 ? context.get('model.engine.id') : context.model.backend; + let id = context.model.id; + let path = context.isV2 ? `${backend}/data/${id}` : `${backend}/${id}`; + return { + id: path, + }; + }, + 'isV2', + 'model', + 'model.id', + 'mode' + ), + canDelete: alias('updatePath.canDelete'), + canEdit: alias('updatePath.canUpdate'), - requestInFlight: or('key.isLoading', 'key.isReloading', 'key.isSaving'), + v2UpdatePath: maybeQueryRecord( + 'capabilities', + context => { + if (context.mode === 'create' || context.isV2 === false) { + return; + } + let backend = context.get('model.engine.id'); + let id = context.model.id; + return { + id: `${backend}/metadata/${id}`, + }; + }, + 'isV2', + 'model', + 'model.id', + 'mode' + ), + canEditV2Secret: alias('v2UpdatePath.canUpdate'), + + deleteVersionPath: maybeQueryRecord( + 'capabilities', + context => { + let backend = context.get('model.engine.id'); + let id = context.model.id; + return { + id: `${backend}/delete/${id}`, + }; + }, + 'model.id' + ), + canDeleteVersion: alias('deleteVersionPath.canUpdate'), + destroyVersionPath: maybeQueryRecord( + 'capabilities', + context => { + let backend = context.get('model.engine.id'); + let id = context.model.id; + return { + id: `${backend}/destroy/${id}`, + }; + }, + 'model.id' + ), + canDestroyVersion: alias('destroyVersionPath.canUpdate'), + undeleteVersionPath: maybeQueryRecord( + 'capabilities', + context => { + let backend = context.get('model.engine.id'); + let id = context.model.id; + return { + id: `${backend}/undelete/${id}`, + }; + }, + 'model.id' + ), + canUndeleteVersion: alias('undeleteVersionPath.canUpdate'), + + isFetchingVersionCapabilities: or( + 'deleteVersionPath.isPending', + 'destroyVersionPath.isPending', + 'undeleteVersionPath.isPending' + ), + + requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'), buttonDisabled: or( 'requestInFlight', - 'key.isFolder', - 'key.isError', - 'key.flagsIsInvalid', + 'model.isFolder', + 'model.isError', + 'model.flagsIsInvalid', 'hasLintError', 'error' ), + modelForData: computed('isV2', 'model', function() { + return this.isV2 ? this.model.belongsTo('selectedVersion').value() : this.model; + }), + basicModeDisabled: computed('secretDataIsAdvanced', 'showAdvancedMode', function() { - return this.get('secretDataIsAdvanced') || this.get('showAdvancedMode') === false; + return this.secretDataIsAdvanced || this.showAdvancedMode === false; }), secretDataAsJSON: computed('secretData', 'secretData.[]', function() { - return this.get('secretData').toJSON(); + return this.secretData.toJSON(); }), secretDataIsAdvanced: computed('secretData', 'secretData.[]', function() { - return this.get('secretData').isAdvanced(); + return this.secretData.isAdvanced(); }), - hasDataChanges() { - const keyDataString = this.get('key.dataAsJSONString'); - const sameData = this.get('secretData').toJSONString() === keyDataString; - if (sameData === false) { - this.set('lastChange', Date.now()); - } - - this.get('onDataChange')(!sameData); - }, - showAdvancedMode: computed('preferAdvancedEdit', 'secretDataIsAdvanced', 'lastChange', function() { - return this.get('secretDataIsAdvanced') || this.get('preferAdvancedEdit'); + return this.secretDataIsAdvanced || this.preferAdvancedEdit; }), transitionToRoute() { - this.get('router').transitionTo(...arguments); + this.router.transitionTo(...arguments); }, onEscape(e) { - if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') { + if (e.keyCode !== keys.ESC || this.mode !== 'show') { return; } - const parentKey = this.get('key.parentKey'); + const parentKey = this.model.parentKey; if (parentKey) { this.transitionToRoute(LIST_ROUTE, parentKey); } else { @@ -139,111 +218,118 @@ export default Component.extend(FocusOnInsertMixin, { }, // successCallback is called in the context of the component - persistKey(method, successCallback, isCreate) { - let model = this.get('key'); - let key = model.get('id'); + persistKey(successCallback) { + let secret = this.model; + let model = this.modelForData; + let isV2 = this.isV2; + let key = model.get('path') || model.id; if (key.startsWith('/')) { key = key.replace(/^\/+/g, ''); - model.set('id', key); + model.set(model.pathAttr, key); } - if (isCreate && typeof model.createRecord === 'function') { - // create an ember data model from the proxy - model = model.createRecord(model.get('backend')); - this.set('key', model); - } - - return model[method]().then(() => { - if (!get(model, 'isError')) { - if (this.get('wizard.featureState') === 'secret') { - this.get('wizard').transitionFeatureMachine('secret', 'CONTINUE'); + return model.save().then(() => { + if (!model.isError) { + if (isV2 && Object.keys(secret.changedAttributes()).length) { + secret.set('id', key); + // save secret metadata + secret + .save() + .then(() => { + this.saveComplete(successCallback, key); + }) + .catch(e => { + this.set(e, e.errors.join(' ')); + }); + } else { + this.saveComplete(successCallback, key); } - successCallback(key); } }); }, + saveComplete(callback, key) { + if (this.wizard.featureState === 'secret') { + this.wizard.transitionFeatureMachine('secret', 'CONTINUE'); + } + callback(key); + }, checkRows() { - if (this.get('secretData').get('length') === 0) { + if (this.secretData.length === 0) { this.send('addRow'); } }, actions: { + //submit on shift + enter handleKeyDown(e) { e.stopPropagation(); if (!(e.keyCode === keys.ENTER && e.metaKey)) { return; } - let $form = this.$('form'); + let $form = this.element.querySelector('form'); if ($form.length) { $form.submit(); } - $form = null; }, handleChange() { - this.set('codemirrorString', this.get('secretData').toJSONString(true)); - this.hasDataChanges(); + this.set('codemirrorString', this.secretData.toJSONString(true)); + set(this.modelForData, 'secretData', this.secretData.toJSON()); }, createOrUpdateKey(type, event) { event.preventDefault(); - const newData = this.get('secretData').toJSON(); - this.get('key').set('secretData', newData); - + let model = this.modelForData; // prevent from submitting if there's no key // maybe do something fancier later - if (type === 'create' && isBlank(this.get('key.id'))) { + if (type === 'create' && isBlank(model.get('path') || model.id)) { return; } - this.persistKey( - 'save', - key => { - this.hasDataChanges(); - this.transitionToRoute(SHOW_ROUTE, key); - }, - type === 'create' - ); + this.persistKey(key => { + this.transitionToRoute(SHOW_ROUTE, key); + }); }, deleteKey() { - this.persistKey('destroyRecord', () => { + this.model.destroyRecord().then(() => { this.transitionToRoute(LIST_ROOT_ROUTE); }); }, + deleteVersion(deleteType = 'destroy') { + let id = this.modelForData.id; + return this.store.adapterFor('secret-v2-version').v2DeleteOperation(this.store, id, deleteType); + }, + refresh() { - this.get('onRefresh')(); + this.onRefresh(); }, addRow() { - const data = this.get('secretData'); + const data = this.secretData; if (isNone(data.findBy('name', ''))) { data.pushObject({ name: '', value: '' }); - this.set('codemirrorString', data.toJSONString(true)); + this.send('handleChange'); } this.checkRows(); - this.hasDataChanges(); }, deleteRow(name) { - const data = this.get('secretData'); + const data = this.secretData; const item = data.findBy('name', name); if (isBlank(item.name)) { return; } data.removeObject(item); this.checkRows(); - this.hasDataChanges(); - this.set('codemirrorString', data.toJSONString(true)); - this.rerender(); + this.send('handleChange'); }, toggleAdvanced(bool) { - this.get('onToggleAdvancedEdit')(bool); + this.onToggleAdvancedEdit(bool); }, codemirrorUpdated(val, codemirror) { @@ -252,7 +338,7 @@ export default Component.extend(FocusOnInsertMixin, { const noErrors = codemirror.state.lint.marked.length === 0; if (noErrors) { try { - this.get('secretData').fromJSONString(val); + this.secretData.fromJSONString(val); } catch (e) { this.set('error', e.message); } @@ -262,7 +348,7 @@ export default Component.extend(FocusOnInsertMixin, { }, formatJSON() { - this.set('codemirrorString', this.get('secretData').toJSONString(true)); + this.set('codemirrorString', this.secretData.toJSONString(true)); }, }, }); diff --git a/ui/app/components/wizard-content.js b/ui/app/components/wizard-content.js index 7d1112fd88..bf2e036212 100644 --- a/ui/app/components/wizard-content.js +++ b/ui/app/components/wizard-content.js @@ -1,13 +1,100 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { FEATURE_MACHINE_STEPS, INIT_STEPS } from 'vault/helpers/wizard-constants'; + export default Component.extend({ wizard: service(), classNames: ['ui-wizard'], glyph: null, headerText: null, + selectProgress: null, + currentMachine: computed.alias('wizard.currentMachine'), + tutorialState: computed.alias('wizard.currentState'), + tutorialComponent: computed.alias('wizard.tutorialComponent'), + showProgress: computed('wizard.featureComponent', 'tutorialState', function() { + return ( + this.tutorialComponent.includes('active') && + (this.tutorialState.includes('init.active') || + (this.wizard.featureComponent && this.wizard.featureMachineHistory)) + ); + }), + featureMachineHistory: computed.alias('wizard.featureMachineHistory'), + totalFeatures: computed('wizard.featureList', function() { + return this.wizard.featureList.length; + }), + completedFeatures: computed('wizard.currentMachine', function() { + return this.wizard.getCompletedFeatures(); + }), + currentFeatureProgress: computed('featureMachineHistory.[]', function() { + if (this.tutorialState.includes('active.feature')) { + let totalSteps = FEATURE_MACHINE_STEPS[this.currentMachine]; + if (this.currentMachine === 'secrets') { + if (this.featureMachineHistory.includes('secret')) { + totalSteps = totalSteps['secret']['secret']; + } + if (this.featureMachineHistory.includes('list')) { + totalSteps = totalSteps['secret']['list']; + } + if (this.featureMachineHistory.includes('encryption')) { + totalSteps = totalSteps['encryption']; + } + if (this.featureMachineHistory.includes('role') || typeof totalSteps === 'object') { + totalSteps = totalSteps['role']; + } + } + return { + percentage: (this.featureMachineHistory.length / totalSteps) * 100, + feature: this.currentMachine, + text: `Step ${this.featureMachineHistory.length} of ${totalSteps}`, + }; + } + return null; + }), + currentTutorialProgress: computed('tutorialState', function() { + if (this.tutorialState.includes('init.active')) { + let currentStepName = this.tutorialState.split('.')[2]; + let currentStepNumber = INIT_STEPS.indexOf(currentStepName) + 1; + return { + percentage: (currentStepNumber / INIT_STEPS.length) * 100, + text: `Step ${currentStepNumber} of ${INIT_STEPS.length}`, + }; + } + return null; + }), + progressBar: computed('currentFeatureProgress', 'currentFeature', 'currentTutorialProgress', function() { + let bar = []; + if (this.currentTutorialProgress) { + bar.push({ + style: `width:${this.currentTutorialProgress.percentage}%;`, + completed: false, + showIcon: true, + }); + } else { + if (this.currentFeatureProgress) { + this.completedFeatures.forEach(feature => { + bar.push({ style: 'width:100%;', completed: true, feature: feature, showIcon: true }); + }); + this.wizard.featureList.forEach(feature => { + if (feature === this.currentMachine) { + bar.push({ + style: `width:${this.currentFeatureProgress.percentage}%;`, + completed: this.currentFeatureProgress.percentage == 100 ? true : false, + feature: feature, + showIcon: true, + }); + } else { + bar.push({ style: 'width:0%;', completed: false, feature: feature, showIcon: true }); + } + }); + } + } + return bar; + }), + actions: { dismissWizard() { - this.get('wizard').transitionTutorialMachine(this.get('wizard.currentState'), 'DISMISS'); + this.wizard.transitionTutorialMachine(this.wizard.currentState, 'DISMISS'); }, }, }); diff --git a/ui/app/components/wizard/features-selection.js b/ui/app/components/wizard/features-selection.js index 6e6f7f590f..f43bd4b456 100644 --- a/ui/app/components/wizard/features-selection.js +++ b/ui/app/components/wizard/features-selection.js @@ -1,10 +1,12 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; +import { FEATURE_MACHINE_TIME } from 'vault/helpers/wizard-constants'; export default Component.extend({ wizard: service(), version: service(), + init() { this._super(...arguments); this.maybeHideFeatures(); @@ -16,7 +18,24 @@ export default Component.extend({ feature.show = false; } }, - + estimatedTime: computed('selectedFeatures', function() { + let time = 0; + for (let feature of Object.keys(FEATURE_MACHINE_TIME)) { + if (this.selectedFeatures.includes(feature)) { + time += FEATURE_MACHINE_TIME[feature]; + } + } + return time; + }), + selectProgress: computed('selectedFeatures', function() { + let bar = this.selectedFeatures.map(feature => { + return { style: 'width:0%;', completed: false, showIcon: true, feature: feature }; + }); + if (bar.length === 0) { + bar = [{ style: 'width:0%;', showIcon: false }]; + } + return bar; + }), allFeatures: computed(function() { return [ { diff --git a/ui/app/controllers/vault/cluster/secrets/backend/actions.js b/ui/app/controllers/vault/cluster/secrets/backend/actions.js index 49f5834dff..17f11291f3 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/actions.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/actions.js @@ -12,9 +12,5 @@ export default Controller.extend(BackendCrumbMixin, { // so we have to manually bubble here this.send('refreshModel'); }, - - hasChanges(hasChanges) { - this.send('hasDataChanges', hasChanges); - }, }, }); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/create.js b/ui/app/controllers/vault/cluster/secrets/backend/create.js index e50b8e2d82..abf822816e 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/create.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/create.js @@ -11,9 +11,6 @@ export default Controller.extend(BackendCrumbMixin, { refresh: function() { this.send('refreshModel'); }, - hasChanges(hasChanges) { - this.send('hasDataChanges', hasChanges); - }, toggleAdvancedEdit(bool) { this.set('preferAdvancedEdit', bool); this.get('backendController').set('preferAdvancedEdit', bool); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/edit.js b/ui/app/controllers/vault/cluster/secrets/backend/edit.js index 47bbcfa66c..b3acca8613 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/edit.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/edit.js @@ -3,6 +3,11 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb'; export default Controller.extend(BackendCrumbMixin, { backendController: controller('vault.cluster.secrets.backend'), + queryParams: ['version'], + version: '', + reset() { + this.set('version', ''); + }, actions: { refresh: function() { // closure actions don't bubble to routes, @@ -10,10 +15,6 @@ export default Controller.extend(BackendCrumbMixin, { this.send('refreshModel'); }, - hasChanges(hasChanges) { - this.send('hasDataChanges', hasChanges); - }, - toggleAdvancedEdit(bool) { this.set('preferAdvancedEdit', bool); this.get('backendController').set('preferAdvancedEdit', bool); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js index 57a56fce50..67e80153d3 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/list.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -65,11 +65,14 @@ export default Controller.extend(BackendCrumbMixin, { }); }, - delete(item) { + delete(item, type) { const name = item.id; item.destroyRecord().then(() => { - this.send('reload'); this.get('flashMessages').success(`${name} was successfully deleted.`); + this.send('reload'); + if (type === 'secret') { + return this.transitionToRoute('vault.cluster.secrets.backend.list-root'); + } }); }, }, diff --git a/ui/app/controllers/vault/cluster/secrets/backend/show.js b/ui/app/controllers/vault/cluster/secrets/backend/show.js index 3f9c262424..26c00b359f 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/show.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/show.js @@ -3,10 +3,12 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb'; export default Controller.extend(BackendCrumbMixin, { backendController: controller('vault.cluster.secrets.backend'), - queryParams: ['tab'], + queryParams: ['tab', 'version'], + version: '', tab: '', reset() { this.set('tab', ''); + this.set('version', ''); }, actions: { refresh: function() { @@ -15,10 +17,6 @@ export default Controller.extend(BackendCrumbMixin, { this.send('refreshModel'); }, - hasChanges(hasChanges) { - this.send('hasDataChanges', hasChanges); - }, - toggleAdvancedEdit(bool) { this.set('preferAdvancedEdit', bool); this.get('backendController').set('preferAdvancedEdit', bool); diff --git a/ui/app/helpers/wizard-constants.js b/ui/app/helpers/wizard-constants.js index f3b1e13b23..93b070887f 100644 --- a/ui/app/helpers/wizard-constants.js +++ b/ui/app/helpers/wizard-constants.js @@ -9,6 +9,7 @@ export const STORAGE_KEYS = { TUTORIAL_STATE: 'vault:ui-tutorial-state', FEATURE_LIST: 'vault:ui-feature-list', FEATURE_STATE: 'vault:ui-feature-state', + FEATURE_STATE_HISTORY: 'vault:ui-feature-state-history', COMPLETED_FEATURES: 'vault:ui-completed-list', COMPONENT_STATE: 'vault:ui-component-state', RESUME_URL: 'vault:ui-tutorial-resume-url', @@ -36,4 +37,30 @@ export const DEFAULTS = { componentState: null, nextFeature: null, nextStep: null, + featureMachineHistory: null, +}; + +export const FEATURE_MACHINE_STEPS = { + secrets: { + encryption: 5, + secret: { + list: 4, + secret: 5, + }, + role: 7, + }, + policies: 5, + replication: 2, + tools: 8, + authentication: 4, +}; + +export const INIT_STEPS = ['setup', 'save', 'unseal', 'login']; + +export const FEATURE_MACHINE_TIME = { + secrets: 7, + policies: 5, + replication: 5, + tools: 8, + authentication: 5, }; diff --git a/ui/app/machines/tutorial-machine.js b/ui/app/machines/tutorial-machine.js index 8ed5ca5adf..9c66d5e9c1 100644 --- a/ui/app/machines/tutorial-machine.js +++ b/ui/app/machines/tutorial-machine.js @@ -16,7 +16,7 @@ export default { { type: 'render', level: 'tutorial', component: 'wizard/tutorial-idle' }, { type: 'render', level: 'feature', component: null }, ], - onExit: ['showTutorialWhenAuthenticated'], + onExit: ['showTutorialWhenAuthenticated', 'clearFeatureData'], states: { idle: { on: { diff --git a/ui/app/macros/lazy-capabilities.js b/ui/app/macros/lazy-capabilities.js index 7756f87f4b..2169d61ef2 100644 --- a/ui/app/macros/lazy-capabilities.js +++ b/ui/app/macros/lazy-capabilities.js @@ -11,7 +11,7 @@ // }); // -import { queryRecord } from 'ember-computed-query'; +import { maybeQueryRecord } from 'vault/macros/maybe-query-record'; export function apiPath(strings, ...keys) { return function(data) { @@ -26,11 +26,25 @@ export function apiPath(strings, ...keys) { export default function() { let [templateFn, ...keys] = arguments; - return queryRecord( + return maybeQueryRecord( 'capabilities', context => { + // pull all context attrs + let contextObject = context.getProperties(...keys); + // remove empty ones + let nonEmptyContexts = Object.keys(contextObject).reduce((ret, key) => { + if (contextObject[key] != null) { + ret[key] = contextObject[key]; + } + return ret; + }, {}); + // if all of them aren't present, cancel the fetch + if (Object.keys(nonEmptyContexts).length !== keys.length) { + return; + } + // otherwise proceed with the capabilities check return { - id: templateFn(context.getProperties(...keys)), + id: templateFn(nonEmptyContexts), }; }, ...keys diff --git a/ui/app/macros/maybe-query-record.js b/ui/app/macros/maybe-query-record.js new file mode 100644 index 0000000000..d27a2323ce --- /dev/null +++ b/ui/app/macros/maybe-query-record.js @@ -0,0 +1,17 @@ +import { computed } from '@ember/object'; +import ObjectProxy from '@ember/object/proxy'; +import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; +import { resolve } from 'rsvp'; + +export function maybeQueryRecord(modelName, options = {}, ...keys) { + return computed(...keys, { + get() { + const query = typeof options === 'function' ? options(this) : options; + const PromiseObject = ObjectProxy.extend(PromiseProxyMixin); + + return PromiseObject.create({ + promise: query ? this.get('store').queryRecord(modelName, query) : resolve({}), + }); + }, + }); +} diff --git a/ui/app/mixins/focus-on-insert.js b/ui/app/mixins/focus-on-insert.js index def2f1021f..6ce409d68e 100644 --- a/ui/app/mixins/focus-on-insert.js +++ b/ui/app/mixins/focus-on-insert.js @@ -22,9 +22,9 @@ export default Mixin.create({ }, forceFocus() { - var $selector = this.$(this.get('focusOnInsertSelector') || 'input').first(); - if (!$selector.is(':focus')) { - $selector.focus(); + var $targ = this.element.querySelectorAll(this.get('focusOnInsertSelector') || 'input[type="text"]')[0]; + if ($targ && $targ !== document.activeElement) { + $targ.focus(); } }, }); diff --git a/ui/app/mixins/key-mixin.js b/ui/app/mixins/key-mixin.js new file mode 100644 index 0000000000..ec0c1dc035 --- /dev/null +++ b/ui/app/mixins/key-mixin.js @@ -0,0 +1,54 @@ +import { computed } from '@ember/object'; +import Mixin from '@ember/object/mixin'; +import utils from '../lib/key-utils'; + +export default Mixin.create({ + // what attribute has the path for the key + // will.be 'path' for v2 or 'id' v1 + pathAttr: 'id', + flags: null, + + initialParentKey: null, + + isCreating: computed('initialParentKey', function() { + return this.get('initialParentKey') != null; + }), + + pathVal() { + return this.get(this.pathAttr); + }, + + // rather than using defineProperty for all of these, + // we're just going to hardcode the known keys for the path ('id' and 'path') + isFolder: computed('id', 'path', function() { + return utils.keyIsFolder(this.pathVal()); + }), + + keyParts: computed('id', 'path', function() { + return utils.keyPartsForKey(this.pathVal()); + }), + + parentKey: computed('id', 'path', 'isCreating', { + get: function() { + return this.isCreating ? this.initialParentKey : utils.parentKeyForKey(this.pathVal()); + }, + set: function(_, value) { + return value; + }, + }), + + keyWithoutParent: computed('id', 'path', 'parentKey', { + get: function() { + var key = this.pathVal(); + return key ? key.replace(this.parentKey, '') : null; + }, + set: function(_, value) { + if (value && value.trim()) { + this.set(this.pathAttr, this.parentKey + value); + } else { + this.set(this.pathAttr, null); + } + return value; + }, + }), +}); diff --git a/ui/app/models/auth-config/radius.js b/ui/app/models/auth-config/radius.js index c47351f3d5..d88088a3f4 100644 --- a/ui/app/models/auth-config/radius.js +++ b/ui/app/models/auth-config/radius.js @@ -27,13 +27,17 @@ export default AuthConfig.extend({ label: 'NAS Port', }), + nasIdentifier: attr('string', { + label: 'NAS Identifier', + }), + fieldGroups: computed(function() { const groups = [ { default: ['host', 'secret'], }, { - 'RADIUS Options': ['port', 'nasPort', 'dialTimeout', 'unregisteredUserPolicies'], + 'RADIUS Options': ['port', 'nasPort', 'nasIdentifier', 'dialTimeout', 'unregisteredUserPolicies'], }, ]; return fieldToAttrs(this, groups); diff --git a/ui/app/models/key-mixin.js b/ui/app/models/key-mixin.js deleted file mode 100644 index 8b505ece50..0000000000 --- a/ui/app/models/key-mixin.js +++ /dev/null @@ -1,45 +0,0 @@ -import { computed } from '@ember/object'; -import Mixin from '@ember/object/mixin'; -import utils from '../lib/key-utils'; - -export default Mixin.create({ - flags: null, - - initialParentKey: null, - - isCreating: computed('initialParentKey', function() { - return this.get('initialParentKey') != null; - }), - - isFolder: computed('id', function() { - return utils.keyIsFolder(this.get('id')); - }), - - keyParts: computed('id', function() { - return utils.keyPartsForKey(this.get('id')); - }), - - parentKey: computed('id', 'isCreating', { - get: function() { - return this.get('isCreating') ? this.get('initialParentKey') : utils.parentKeyForKey(this.get('id')); - }, - set: function(_, value) { - return value; - }, - }), - - keyWithoutParent: computed('id', 'parentKey', { - get: function() { - var key = this.get('id'); - return key ? key.replace(this.get('parentKey'), '') : null; - }, - set: function(_, value) { - if (value && value.trim()) { - this.set('id', this.get('parentKey') + value); - } else { - this.set('id', null); - } - return value; - }, - }), -}); diff --git a/ui/app/models/lease.js b/ui/app/models/lease.js index bbf271ad62..73d299065e 100644 --- a/ui/app/models/lease.js +++ b/ui/app/models/lease.js @@ -1,6 +1,6 @@ import { match } from '@ember/object/computed'; import DS from 'ember-data'; -import KeyMixin from './key-mixin'; +import KeyMixin from 'vault/mixins/key-mixin'; const { attr } = DS; /* sample response diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index 89ceef238d..a59099a74e 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -1,7 +1,6 @@ import { computed } from '@ember/object'; import DS from 'ember-data'; import { fragment } from 'ember-data-model-fragments/attributes'; - import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; const { attr } = DS; @@ -41,6 +40,10 @@ export default DS.Model.extend({ return modelType; }), + isV2KV: computed('modelTypeForKV', function() { + return this.modelTypeForKV === 'secret-v2'; + }), + formFields: computed('engineType', function() { let type = this.get('engineType'); let fields = [ diff --git a/ui/app/models/secret-v2-version.js b/ui/app/models/secret-v2-version.js new file mode 100644 index 0000000000..b4ddc77219 --- /dev/null +++ b/ui/app/models/secret-v2-version.js @@ -0,0 +1,17 @@ +import Secret from './secret'; +import DS from 'ember-data'; +import { bool } from '@ember/object/computed'; + +const { attr, belongsTo } = DS; + +export default Secret.extend({ + pathAttr: 'path', + version: attr('number'), + secret: belongsTo('secret-v2'), + path: attr('string'), + deletionTime: attr('string'), + createdTime: attr('string'), + deleted: bool('deletionTime'), + destroyed: attr('boolean'), + currentVersion: attr('number'), +}); diff --git a/ui/app/models/secret-v2.js b/ui/app/models/secret-v2.js index fcbf908caf..d68f9e4353 100644 --- a/ui/app/models/secret-v2.js +++ b/ui/app/models/secret-v2.js @@ -1,3 +1,38 @@ -import Secret from './secret'; +import DS from 'ember-data'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import KeyMixin from 'vault/mixins/key-mixin'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; -export default Secret.extend(); +const { attr, hasMany, belongsTo, Model } = DS; + +export default Model.extend(KeyMixin, { + engine: belongsTo('secret-engine', { async: false }), + engineId: attr('string'), + versions: hasMany('secret-v2-version', { async: false, inverse: null }), + selectedVersion: belongsTo('secret-v2-version', { async: false, inverse: 'secret' }), + createdTime: attr(), + updatedTime: attr(), + currentVersion: attr('number'), + oldestVersion: attr('number'), + maxVersions: attr('number', { + defaultValue: 10, + label: 'Maximum Number of Versions', + }), + casRequired: attr('boolean', { + defaultValue: false, + label: 'Require Check and Set', + helpText: + 'Writes will only be allowed if the key’s current version matches the version specified in the cas parameter', + }), + fields: computed(function() { + return expandAttributeMeta(this, ['maxVersions', 'casRequired']); + }), + versionPath: lazyCapabilities(apiPath`${'engineId'}/data/${'id'}`, 'engineId', 'id'), + secretPath: lazyCapabilities(apiPath`${'engineId'}/metadata/${'id'}`, 'engineId', 'id'), + + canEdit: alias('versionPath.canUpdate'), + canDelete: alias('secretPath.canUpdate'), + canRead: alias('secretPath.canRead'), +}); diff --git a/ui/app/models/secret.js b/ui/app/models/secret.js index 4e1e7abcb2..36fc8eeeaf 100644 --- a/ui/app/models/secret.js +++ b/ui/app/models/secret.js @@ -1,7 +1,9 @@ import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import DS from 'ember-data'; -import KeyMixin from './key-mixin'; +import KeyMixin from 'vault/mixins/key-mixin'; const { attr } = DS; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; export default DS.Model.extend(KeyMixin, { auth: attr('string'), @@ -21,5 +23,10 @@ export default DS.Model.extend(KeyMixin, { }), helpText: attr('string'), + // TODO this needs to be a relationship like `engine` on kv-v2 backend: attr('string'), + secretPath: lazyCapabilities(apiPath`${'backend'}/${'id'}`, 'backend', 'id'), + canEdit: alias('secretPath.canUpdate'), + canDelete: alias('secretPath.canUpdate'), + canRead: alias('secretPath.canRead'), }); diff --git a/ui/app/routes/vault/cluster/secrets/backend.js b/ui/app/routes/vault/cluster/secrets/backend.js index c7769c9f5a..b7964a59b1 100644 --- a/ui/app/routes/vault/cluster/secrets/backend.js +++ b/ui/app/routes/vault/cluster/secrets/backend.js @@ -16,16 +16,8 @@ export default Route.extend({ }, afterModel(model, transition) { - let target = transition.targetName; let path = model && model.get('path'); - let type = model && model.get('type'); - if (type === 'kv' && model.get('options.version') === 2) { - this.get('flashMessages').stickyInfo( - `"${path}" is a newer version of the KV backend. The Vault UI does not currently support the additional versioning features. All actions taken through the UI in this engine will operate on the most recent version of a secret.` - ); - } - - if (target === this.routeName) { + if (transition.targetName === this.routeName) { return this.replaceWith('vault.cluster.secrets.backend.list-root', path); } }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index 28d5c8ccc5..a8aa145f87 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -1 +1,45 @@ -export { default } from './create'; +import { hash } from 'rsvp'; +import { inject as service } from '@ember/service'; +import EditBase from './secret-edit'; + +let secretModel = (store, backend, key) => { + let backendModel = store.peekRecord('secret-engine', backend); + let modelType = backendModel.get('modelTypeForKV'); + if (modelType !== 'secret-v2') { + return store.createRecord(modelType, { + id: key, + }); + } + let secret = store.createRecord(modelType); + secret.set('engine', backendModel); + let version = store.createRecord('secret-v2-version', { + path: key, + }); + secret.set('selectedVersion', version); + return secret; +}; + +export default EditBase.extend({ + wizard: service(), + createModel(transition) { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const modelType = this.modelType(backend); + if (modelType === 'role-ssh') { + 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); + } + return secretModel(this.store, backend, transition.queryParams.initialKey); + }, + + model(params, transition) { + return hash({ + secret: this.createModel(transition), + capabilities: {}, + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/create.js b/ui/app/routes/vault/cluster/secrets/backend/create.js index 9f9a188cf1..e60011aea0 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create.js @@ -1,59 +1,10 @@ -import { hash } from 'rsvp'; -import { inject as service } from '@ember/service'; -import EmberObject from '@ember/object'; -import EditBase from './secret-edit'; -import KeyMixin from 'vault/models/key-mixin'; +import Route from '@ember/routing/route'; -var SecretProxy = EmberObject.extend(KeyMixin, { - store: null, - - toModel() { - return this.getProperties('id', 'secretData', 'backend'); - }, - - createRecord(backend) { - let backendModel = this.store.peekRecord('secret-engine', backend); - return this.store.createRecord(backendModel.get('modelTypeForKV'), this.toModel()); - }, - - willDestroy() { - this.store = null; - }, -}); - -export default EditBase.extend({ - wizard: service(), - createModel(transition, parentKey) { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); - const modelType = this.modelType(backend); - if (modelType === 'role-ssh') { - 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 || ''; - const model = SecretProxy.create({ - initialParentKey: parentKey, - store: this.store, - }); - - if (key) { - // have to set this after so that it will be - // computed properly in the template (it's dependent on `initialParentKey`) - model.set('keyWithoutParent', key); - } - return model; - }, - - model(params, transition) { - const parentKey = params.secret ? params.secret : ''; - return hash({ - secret: this.createModel(transition, parentKey), - capabilities: {}, +export default Route.extend({ + beforeModel() { + let { secret } = this.paramsFor(this.routeName); + return this.transitionTo('vault.cluster.secrets.backend.create-root', { + queryParams: { initialKey: secret }, }); }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/edit.js b/ui/app/routes/vault/cluster/secrets/backend/edit.js index 69b9f2e0bd..1f4f747340 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/edit.js @@ -1,3 +1,9 @@ import EditBase from './secret-edit'; -export default EditBase.extend(); +export default EditBase.extend({ + queryParams: { + version: { + refreshModel: true, + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 6f8694cd7c..18a927f8eb 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -166,8 +166,8 @@ export default Route.extend({ return true; }, reload() { - this.refresh(); this.store.clearAllDatasets(); + this.refresh(); }, }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index fb0cbb64ac..42da97bf16 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -1,22 +1,23 @@ import { set } from '@ember/object'; -import { hash } from 'rsvp'; +import { hash, resolve } from 'rsvp'; import Route from '@ember/routing/route'; import utils from 'vault/lib/key-utils'; import UnloadModelRoute from 'vault/mixins/unload-model-route'; +import DS from 'ember-data'; export default Route.extend(UnloadModelRoute, { capabilities(secret) { const { backend } = this.paramsFor('vault.cluster.secrets.backend'); let backendModel = this.modelFor('vault.cluster.secrets.backend'); let backendType = backendModel.get('engineType'); - let version = backendModel.get('options.version'); + if (backendType === 'kv' || backendType === 'cubbyhole' || backendType === 'generic') { + return resolve({}); + } let path; if (backendType === 'transit') { path = backend + '/keys/' + secret; } else if (backendType === 'ssh' || backendType === 'aws') { path = backend + '/roles/' + secret; - } else if (version && version === 2) { - path = backend + '/data/' + secret; } else { path = backend + '/' + secret; } @@ -72,7 +73,26 @@ export default Route.extend(UnloadModelRoute, { secret = secret.replace('cert/', ''); } return hash({ - secret: this.store.queryRecord(modelType, { id: secret, backend }), + secret: this.store.queryRecord(modelType, { id: secret, backend }).then(resp => { + if (modelType === 'secret-v2') { + let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); + let targetVersion = parseInt(params.version || resp.currentVersion, 10); + let version = resp.versions.findBy('version', targetVersion); + // 404 if there's no version + if (!version) { + let error = new DS.AdapterError(); + set(error, 'httpStatus', 404); + throw error; + } + resp.set('engine', backendModel); + + return version.reload().then(() => { + resp.set('selectedVersion', version); + return resp; + }); + } + return resp; + }), capabilities: this.capabilities(secret), }); }, @@ -120,14 +140,24 @@ export default Route.extend(UnloadModelRoute, { }, willTransition(transition) { - if (this.get('hasChanges')) { + let model = this.controller.model; + let version = model.get('selectedVersion'); + let changed = model.changedAttributes(); + let changedKeys = Object.keys(changed); + // until we have time to move `backend` on a v1 model to a relationship, + // it's going to dirty the model state, so we need to look for it + // and explicity ignore it here + if ( + (changedKeys.length && changedKeys[0] !== 'backend') || + (version && Object.keys(version.changedAttributes()).length) + ) { if ( window.confirm( 'You have unsaved changes. Navigating away will discard these changes. Are you sure you want to discard your changes?' ) ) { + version && version.rollbackAttributes(); this.unloadModel(); - this.set('hasChanges', false); return true; } else { transition.abort(); @@ -136,9 +166,5 @@ export default Route.extend(UnloadModelRoute, { } return this._super(...arguments); }, - - hasDataChanges(hasChanges) { - this.set('hasChanges', hasChanges); - }, }, }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/show.js b/ui/app/routes/vault/cluster/secrets/backend/show.js index 69b9f2e0bd..1f4f747340 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/show.js +++ b/ui/app/routes/vault/cluster/secrets/backend/show.js @@ -1,3 +1,9 @@ import EditBase from './secret-edit'; -export default EditBase.extend(); +export default EditBase.extend({ + queryParams: { + version: { + refreshModel: true, + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/tools/tool.js b/ui/app/routes/vault/cluster/tools/tool.js index 98b0f6d72a..2d7fb47782 100644 --- a/ui/app/routes/vault/cluster/tools/tool.js +++ b/ui/app/routes/vault/cluster/tools/tool.js @@ -26,11 +26,8 @@ export default 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() - ); + if (this.wizard.currentMachine === 'tools') { + this.wizard.transitionFeatureMachine(this.wizard.featureState, params.selected_action.toUpperCase()); } this.controller.setProperties(params); return true; diff --git a/ui/app/serializers/secret-v2-version.js b/ui/app/serializers/secret-v2-version.js new file mode 100644 index 0000000000..d8c77703df --- /dev/null +++ b/ui/app/serializers/secret-v2-version.js @@ -0,0 +1,31 @@ +import { get } from '@ember/object'; +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + secretDataPath: 'data.data', + normalizeItems(payload) { + let path = this.secretDataPath; + // move response that is the contents of the secret from the dataPath + // to `secret_data` so it will be `secretData` in the model + payload.secret_data = get(payload, path); + payload = Object.assign({}, payload, payload.data.metadata); + delete payload.data; + payload.path = payload.id; + // return the payload if it's expecting a single object or wrap + // it as an array if not + return payload; + }, + serialize(snapshot) { + let version = 0; + let secret = snapshot.belongsTo('secret'); + if (secret) { + version = secret.attr('currentVersion'); + } + return { + data: snapshot.attr('secretData'), + options: { + cas: version, + }, + }; + }, +}); diff --git a/ui/app/serializers/secret-v2.js b/ui/app/serializers/secret-v2.js index 9a43c82b08..e1d7fc7f6c 100644 --- a/ui/app/serializers/secret-v2.js +++ b/ui/app/serializers/secret-v2.js @@ -1,5 +1,47 @@ -import SecretSerializer from './secret'; +import ApplicationSerializer from './application'; +import DS from 'ember-data'; -export default SecretSerializer.extend({ - secretDataPath: 'data.data', +export default ApplicationSerializer.extend(DS.EmbeddedRecordsMixin, { + attrs: { + versions: { embedded: 'always' }, + }, + secretDataPath: 'data', + normalizeItems(payload, requestType) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + // if we have data.keys, it's a list of ids, so we map over that + // and create objects with id's + return payload.data.keys.map(secret => { + // secrets don't have an id in the response, so we need to concat the full + // path of the secret here - the id in the payload is added + // in the adapter after making the request + let fullSecretPath = payload.id ? payload.id + secret : secret; + + // if there is no path, it's a "top level" secret, so add + // a unicode space for the id + // https://github.com/hashicorp/vault/issues/3348 + if (!fullSecretPath) { + fullSecretPath = '\u0020'; + } + return { + id: fullSecretPath, + engine_id: payload.backend, + }; + }); + } + // transform versions to an array with composite IDs + if (payload.data.versions) { + payload.data.versions = Object.keys(payload.data.versions).map(version => { + let body = payload.data.versions[version]; + body.version = version; + body.path = payload.id; + body.id = JSON.stringify([payload.backend, payload.id, version]); + return body; + }); + } + payload.data.id = payload.id; + return requestType === 'queryRecord' ? payload.data : [payload.data]; + }, + serializeHasMany() { + return; + }, }); diff --git a/ui/app/serializers/secret.js b/ui/app/serializers/secret.js index eb7bc024ef..6b6bb68e62 100644 --- a/ui/app/serializers/secret.js +++ b/ui/app/serializers/secret.js @@ -19,7 +19,7 @@ export default ApplicationSerializer.extend({ if (!fullSecretPath) { fullSecretPath = '\u0020'; } - return { id: fullSecretPath }; + return { id: fullSecretPath, backend: payload.backend }; }); } let path = this.get('secretDataPath'); diff --git a/ui/app/services/csp-event.js b/ui/app/services/csp-event.js index 7cfceae0f4..df1a5779d2 100644 --- a/ui/app/services/csp-event.js +++ b/ui/app/services/csp-event.js @@ -12,19 +12,19 @@ export default Service.extend({ connectionViolations: filterBy('events', 'violatedDirective', 'connect-src'), attach() { - this.get('monitor').perform(); + this.monitor.perform(); }, remove() { - this.get('monitor').cancelAll(); + this.monitor.cancelAll(); }, monitor: task(function*() { - this.get('events').clear(); + this.events.clear(); while (true) { let event = yield waitForEvent(window.document, 'securitypolicyviolation'); - this.get('events').addObject(event); + this.events.addObject(event); } }), }); diff --git a/ui/app/services/wizard.js b/ui/app/services/wizard.js index 4b892f9857..56e96ceefe 100644 --- a/ui/app/services/wizard.js +++ b/ui/app/services/wizard.js @@ -5,56 +5,73 @@ import { Machine } from 'xstate'; import getStorage from 'vault/lib/token-storage'; import { STORAGE_KEYS, DEFAULTS, MACHINES } from 'vault/helpers/wizard-constants'; +const { + TUTORIAL_STATE, + COMPONENT_STATE, + FEATURE_STATE, + FEATURE_LIST, + FEATURE_STATE_HISTORY, + COMPLETED_FEATURES, + RESUME_URL, + RESUME_ROUTE, +} = STORAGE_KEYS; const TutorialMachine = Machine(MACHINES.tutorial); let FeatureMachine = null; export default Service.extend(DEFAULTS, { router: service(), showWhenUnauthenticated: false, - + featureMachineHistory: null, init() { this._super(...arguments); this.initializeMachines(); }, initializeMachines() { - if (!this.storageHasKey(STORAGE_KEYS.TUTORIAL_STATE)) { + if (!this.storageHasKey(TUTORIAL_STATE)) { let state = TutorialMachine.initialState; this.saveState('currentState', state.value); - this.saveExtState(STORAGE_KEYS.TUTORIAL_STATE, state.value); + this.saveExtState(TUTORIAL_STATE, state.value); } - this.saveState('currentState', this.getExtState(STORAGE_KEYS.TUTORIAL_STATE)); - if (this.storageHasKey(STORAGE_KEYS.COMPONENT_STATE)) { - this.set('componentState', this.getExtState(STORAGE_KEYS.COMPONENT_STATE)); + 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')); + let stateNodes = TutorialMachine.getStateNodes(this.currentState); this.executeActions(stateNodes.reduce((acc, node) => acc.concat(node.onEntry), []), null, 'tutorial'); - if (this.storageHasKey(STORAGE_KEYS.FEATURE_LIST)) { - this.set('featureList', this.getExtState(STORAGE_KEYS.FEATURE_LIST)); - if (this.storageHasKey(STORAGE_KEYS.FEATURE_STATE)) { - this.saveState('featureState', this.getExtState(STORAGE_KEYS.FEATURE_STATE)); - } else { - if (FeatureMachine != null) { - this.saveState('featureState', FeatureMachine.initialState); - this.saveExtState(STORAGE_KEYS.FEATURE_STATE, this.get('featureState')); - } + + if (this.storageHasKey(FEATURE_LIST)) { + this.set('featureList', this.getExtState(FEATURE_LIST)); + if (this.storageHasKey(FEATURE_STATE_HISTORY)) { + this.set('featureMachineHistory', this.getExtState(FEATURE_STATE_HISTORY)); } + this.saveState( + 'featureState', + this.getExtState(FEATURE_STATE) || (FeatureMachine ? FeatureMachine.initialState : null) + ); + this.saveExtState(FEATURE_STATE, this.featureState); this.buildFeatureMachine(); } }, - restartGuide() { + clearFeatureData() { let storage = this.storage(); // empty storage - [ - STORAGE_KEYS.TUTORIAL_STATE, - STORAGE_KEYS.FEATURE_LIST, - STORAGE_KEYS.FEATURE_STATE, - STORAGE_KEYS.COMPLETED_FEATURES, - STORAGE_KEYS.COMPONENT_STATE, - STORAGE_KEYS.RESUME_URL, - STORAGE_KEYS.RESUME_ROUTE, - ].forEach(key => storage.removeItem(key)); + [FEATURE_LIST, FEATURE_STATE, FEATURE_STATE_HISTORY, COMPLETED_FEATURES].forEach(key => + storage.removeItem(key) + ); + + this.set('currentMachine', null); + this.set('featureMachineHistory', null); + this.set('featureState', null); + this.set('featureList', null); + }, + + restartGuide() { + this.clearFeatureData(); + let storage = this.storage(); + // empty storage + [TUTORIAL_STATE, COMPONENT_STATE, RESUME_URL, RESUME_ROUTE].forEach(key => storage.removeItem(key)); // reset wizard state this.setProperties(DEFAULTS); // restart machines from blank state @@ -63,6 +80,32 @@ export default Service.extend(DEFAULTS, { this.transitionTutorialMachine('idle', 'AUTH'); }, + saveFeatureHistory(state) { + if ( + this.getCompletedFeatures().length === 0 && + this.featureMachineHistory === null && + (state === 'idle' || state === 'wrap') + ) { + let newHistory = [state]; + this.set('featureMachineHistory', newHistory); + } else { + if (this.featureMachineHistory) { + if (!this.featureMachineHistory.includes(state)) { + let newHistory = this.featureMachineHistory.addObject(state); + this.set('featureMachineHistory', newHistory); + } else { + //we're repeating steps + let stepIndex = this.featureMachineHistory.indexOf(state); + let newHistory = this.featureMachineHistory.splice(0, stepIndex + 1); + this.set('featureMachineHistory', newHistory); + } + } + } + if (this.featureMachineHistory) { + this.saveExtState(FEATURE_STATE_HISTORY, this.featureMachineHistory); + } + }, + saveState(stateType, state) { if (state.value) { state = state.value; @@ -75,40 +118,44 @@ export default Service.extend(DEFAULTS, { } stateKey += state; this.set(stateType, stateKey); + if (stateType === 'featureState') { + //only track progress if we are on the first step of the first feature + this.saveFeatureHistory(state); + } }, transitionTutorialMachine(currentState, event, extendedState) { if (extendedState) { this.set('componentState', extendedState); - this.saveExtState(STORAGE_KEYS.COMPONENT_STATE, extendedState); + this.saveExtState(COMPONENT_STATE, extendedState); } let { actions, value } = TutorialMachine.transition(currentState, event); this.saveState('currentState', value); - this.saveExtState(STORAGE_KEYS.TUTORIAL_STATE, this.get('currentState')); + this.saveExtState(TUTORIAL_STATE, this.currentState); this.executeActions(actions, event, 'tutorial'); }, transitionFeatureMachine(currentState, event, extendedState) { - if (!FeatureMachine || !this.get('currentState').includes('active')) { + if (!FeatureMachine || !this.currentState.includes('active')) { return; } if (extendedState) { this.set('componentState', extendedState); - this.saveExtState(STORAGE_KEYS.COMPONENT_STATE, extendedState); + this.saveExtState(COMPONENT_STATE, extendedState); } let { actions, value } = FeatureMachine.transition(currentState, event, this.get('componentState')); this.saveState('featureState', value); - this.saveExtState(STORAGE_KEYS.FEATURE_STATE, 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')); + if (this.currentMachine === 'secrets' && value === 'display') { + next = FeatureMachine.transition(value, 'REPEAT', this.componentState); } else { - next = FeatureMachine.transition(value, 'CONTINUE', this.get('componentState')); + next = FeatureMachine.transition(value, 'CONTINUE', this.componentState); } this.saveState('nextStep', next.value); } @@ -129,7 +176,7 @@ export default Service.extend(DEFAULTS, { executeActions(actions, event, machineType) { let transitionURL; let expectedRouteName; - let router = this.get('router'); + let router = this.router; for (let action of actions) { let type = action; @@ -168,8 +215,11 @@ export default Service.extend(DEFAULTS, { case 'showTutorialAlways': this.set('showWhenUnauthenticated', true); break; + case 'clearFeatureData': + this.clearFeatureData(); + break; case 'continueFeature': - this.transitionFeatureMachine(this.get('featureState'), 'CONTINUE', this.get('componentState')); + this.transitionFeatureMachine(this.featureState, 'CONTINUE', this.componentState); break; default: break; @@ -192,94 +242,107 @@ export default Service.extend(DEFAULTS, { }, handlePaused() { - let expected = this.get('expectedURL'); + let expected = this.expectedURL; if (expected) { - this.saveExtState(STORAGE_KEYS.RESUME_URL, this.get('expectedURL')); - this.saveExtState(STORAGE_KEYS.RESUME_ROUTE, this.get('expectedRouteName')); + this.saveExtState(RESUME_URL, this.expectedURL); + this.saveExtState(RESUME_ROUTE, this.expectedRouteName); } }, handleResume() { - let resumeURL = this.storage().getItem(STORAGE_KEYS.RESUME_URL); + let resumeURL = this.storage().getItem(RESUME_URL); if (!resumeURL) { return; } - this.get('router').transitionTo(resumeURL).followRedirects().then(() => { - this.set('expectedRouteName', this.storage().getItem(STORAGE_KEYS.RESUME_ROUTE)); - this.set('expectedURL', resumeURL); - this.initializeMachines(); - this.storage().removeItem(STORAGE_KEYS.RESUME_URL); - }); + 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(STORAGE_KEYS.FEATURE_STATE); - this.storage().removeItem(STORAGE_KEYS.FEATURE_LIST); - this.storage().removeItem(STORAGE_KEYS.COMPONENT_STATE); + this.storage().removeItem(FEATURE_STATE); + this.storage().removeItem(FEATURE_LIST); + this.storage().removeItem(FEATURE_STATE_HISTORY); + this.storage().removeItem(COMPONENT_STATE); }, saveFeatures(features) { this.set('featureList', features); - this.saveExtState(STORAGE_KEYS.FEATURE_LIST, this.get('featureList')); + this.saveExtState(FEATURE_LIST, this.featureList); this.buildFeatureMachine(); }, buildFeatureMachine() { - if (this.get('featureList') === null) { + if (this.featureList === null) { return; } this.startFeature(); - if (this.storageHasKey(STORAGE_KEYS.FEATURE_STATE)) { - this.saveState('featureState', this.getExtState(STORAGE_KEYS.FEATURE_STATE)); - } - this.saveExtState(STORAGE_KEYS.FEATURE_STATE, this.get('featureState')); - let nextFeature = - this.get('featureList').length > 1 - ? this.get('featureList') - .objectAt(1) - .capitalize() - : 'Finish'; + let nextFeature = this.featureList.length > 1 ? this.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')); + if (this.currentMachine === 'secrets' && this.featureState === 'display') { + next = FeatureMachine.transition(this.featureState, 'REPEAT', this.componentState); } else { - next = FeatureMachine.transition(this.get('featureState'), 'CONTINUE', this.get('componentState')); + next = FeatureMachine.transition(this.featureState, 'CONTINUE', this.componentState); } this.saveState('nextStep', next.value); - let stateNodes = FeatureMachine.getStateNodes(this.get('featureState')); + let stateNodes = FeatureMachine.getStateNodes(this.featureState); this.executeActions(stateNodes.reduce((acc, node) => acc.concat(node.onEntry), []), null, 'feature'); }, startFeature() { - const FeatureMachineConfig = MACHINES[this.get('featureList').objectAt(0)]; + const FeatureMachineConfig = MACHINES[this.featureList.objectAt(0)]; FeatureMachine = Machine(FeatureMachineConfig); - this.set('currentMachine', this.get('featureList').objectAt(0)); - this.saveState('featureState', FeatureMachine.initialState); + this.set('currentMachine', this.featureList.objectAt(0)); + if (this.storageHasKey(FEATURE_STATE)) { + this.saveState('featureState', this.getExtState(FEATURE_STATE)); + } else { + this.saveState('featureState', FeatureMachine.initialState); + } + this.saveExtState(FEATURE_STATE, this.featureState); + }, + + getCompletedFeatures() { + if (this.storageHasKey(COMPLETED_FEATURES)) { + return this.getExtState(COMPLETED_FEATURES).toArray(); + } + return []; }, completeFeature() { - let features = this.get('featureList'); + let features = this.featureList; let done = features.shift(); - if (!this.getExtState(STORAGE_KEYS.COMPLETED_FEATURES)) { + if (!this.getExtState(COMPLETED_FEATURES)) { let completed = []; completed.push(done); - this.saveExtState(STORAGE_KEYS.COMPLETED_FEATURES, completed); + this.saveExtState(COMPLETED_FEATURES, completed); } else { this.saveExtState( - STORAGE_KEYS.COMPLETED_FEATURES, - this.getExtState(STORAGE_KEYS.COMPLETED_FEATURES).toArray().addObject(done) + COMPLETED_FEATURES, + this.getExtState(COMPLETED_FEATURES) + .toArray() + .addObject(done) ); } - this.saveExtState(STORAGE_KEYS.FEATURE_LIST, features.length ? features : null); - this.storage().removeItem(STORAGE_KEYS.FEATURE_STATE); + this.saveExtState(FEATURE_LIST, features.length ? features : null); + this.storage().removeItem(FEATURE_STATE); + if (this.featureMachineHistory) { + this.set('featureMachineHistory', []); + this.saveExtState(FEATURE_STATE_HISTORY, []); + } if (features.length > 0) { this.buildFeatureMachine(); } else { - this.storage().removeItem(STORAGE_KEYS.FEATURE_LIST); + this.storage().removeItem(FEATURE_LIST); FeatureMachine = null; - this.transitionTutorialMachine(this.get('currentState'), 'DONE'); + this.transitionTutorialMachine(this.currentState, 'DONE'); } }, diff --git a/ui/app/styles/components/console-ui-panel.scss b/ui/app/styles/components/console-ui-panel.scss index f09fe5c0bb..19b0085c59 100644 --- a/ui/app/styles/components/console-ui-panel.scss +++ b/ui/app/styles/components/console-ui-panel.scss @@ -30,6 +30,7 @@ color: inherit; font-size: $body-size; min-height: 2rem; + padding: 0; &:not(.console-ui-command):not(.CodeMirror-line) { padding-left: $console-spacing; @@ -152,14 +153,14 @@ header .navbar-sections { .console-spinner.control { height: 21px; width: 21px; - transform: scale(.75); + transform: scale(0.75); transform-origin: center; &::after { height: auto; width: auto; - right: .25rem; - left: .25rem; - top: .25rem; - bottom: .25rem; + right: 0.25rem; + left: 0.25rem; + top: 0.25rem; + bottom: 0.25rem; } } diff --git a/ui/app/styles/components/info-table-row.scss b/ui/app/styles/components/info-table-row.scss index d7d86f9513..75e1e82277 100644 --- a/ui/app/styles/components/info-table-row.scss +++ b/ui/app/styles/components/info-table-row.scss @@ -1,6 +1,6 @@ .info-table-row { box-shadow: 0 1px 0 $grey-light; - margin: 0 $size-6; + margin: 0; @include from($tablet) { display: flex; @@ -60,4 +60,7 @@ @include until($tablet) { display: none; } + .info-table-row:not(.is-mobile) .column:last-child { + padding-left: 0; + } } diff --git a/ui/app/styles/components/masked-input.scss b/ui/app/styles/components/masked-input.scss index 2c5533b5b0..1117b27261 100644 --- a/ui/app/styles/components/masked-input.scss +++ b/ui/app/styles/components/masked-input.scss @@ -3,7 +3,7 @@ } .masked-input .masked-value { - padding-left: 2.50rem; + padding-left: 2.5rem; } // we want to style the boxes the same everywhere so they @@ -25,6 +25,8 @@ .masked-input .display-only { line-height: 1.5; font-size: 1rem; + padding-top: 0; + padding-bottom: 0; } .masked-input-toggle { diff --git a/ui/app/styles/components/namespace-picker.scss b/ui/app/styles/components/namespace-picker.scss index 2dc07e2cfa..8e9ad3b47a 100644 --- a/ui/app/styles/components/namespace-picker.scss +++ b/ui/app/styles/components/namespace-picker.scss @@ -45,13 +45,6 @@ } } -.namespace-header { - margin: $size-9 $size-9 0; - color: $grey; - font-size: $size-8; - font-weight: $font-weight-semibold; - text-transform: uppercase; -} .current-namespace { border-bottom: 1px solid rgba($black, 0.1); } diff --git a/ui/app/styles/components/secret-control-bar.scss b/ui/app/styles/components/secret-control-bar.scss new file mode 100644 index 0000000000..9384d1d117 --- /dev/null +++ b/ui/app/styles/components/secret-control-bar.scss @@ -0,0 +1,24 @@ +.secret-control-bar { + margin: 0; + padding: $size-10 $size-9; + background: $grey-lighter; + box-shadow: 0 1px 0 $grey-light, 0 -1px 0 $grey-light; + display: flex; + justify-content: flex-end; + .control { + flex: 0 1 auto; + padding: 0 $size-10; + font-size: $size-8; + height: 1.8rem; + line-height: 1.8rem; + display: flex; + flex-direction: column; + justify-content: center; + .switch[type="checkbox"].is-small + label::before { + top: 0.5rem; + } + .switch[type="checkbox"].is-small + label::after { + top: 0.6rem; + } + } +} diff --git a/ui/app/styles/components/ui-wizard.scss b/ui/app/styles/components/ui-wizard.scss index 7a5a8c7c86..8648f8bd78 100644 --- a/ui/app/styles/components/ui-wizard.scss +++ b/ui/app/styles/components/ui-wizard.scss @@ -67,7 +67,7 @@ .wizard-header { border-bottom: $light-border; - padding: $size-8 $size-4 $size-8 2rem; + padding: 0 $size-4 $size-8 2rem; margin: $size-4 0; position: relative; @@ -79,7 +79,7 @@ .title .icon { left: 0; position: absolute; - top: 0.7rem; + top: 0; } } @@ -173,3 +173,91 @@ .wizard-instructions { margin: $size-4 0; } + +.selection-summary { + display: flex; + align-items: center; + width: 100%; + justify-content: space-between; +} + +.time-estimate { + align-items: center; + color: $grey; + display: flex; + font-size: 12px; +} + +.progress-container { + align-items: center; + background: $white; + bottom: 0; + height: $wizard-progress-bar-height; + display: flex; + left: 0; + padding: 0; + position: absolute; + right: 0; + transform: translateY(50%); + width: 100%; +} + +.progress-bar { + background: $ui-gray-100; + box-shadow: inset 0 0 0 1px $ui-gray-200; + display: flex; + height: $wizard-progress-bar-height; + position: relative; + width: 100%; +} + +.feature-progress-container { + align-items: center; + flex: 1 0 auto; + padding: 0 ($wizard-progress-check-size / 4); + position: relative; +} + +.feature-progress { + background: $green; + border-radius: $wizard-progress-bar-height; + height: $wizard-progress-bar-height; +} + +.feature-check { + height: $wizard-progress-check-size; + left: $wizard-progress-check-size / 2; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: $wizard-progress-check-size; + z-index: 10; +} + +.feature-progress-container .feature-check { + left: 100%; +} + +.feature-progress-container:first-child { + padding-left: 0; + + .progress-bar, + .feature-progress { + border-radius: $wizard-progress-bar-height 0 0 $wizard-progress-bar-height; + } +} + +.feature-progress-container:first-child:last-child { + .progress-bar, + .feature-progress { + border-radius: $wizard-progress-bar-height; + } +} + +.incomplete-check svg { + fill: $ui-gray-200; +} + +.completed-check svg { + fill: $green; +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index eb5bfa831b..4fcbff2295 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -71,6 +71,7 @@ @import "./components/popup-menu"; @import "./components/radial-progress"; @import "./components/role-item"; +@import "./components/secret-control-bar"; @import "./components/shamir-progress"; @import "./components/sidebar"; @import "./components/splash-page"; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index d317eaa902..f802a935f6 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -217,14 +217,17 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); } .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; + box-shadow: none; + color: $blue; + display: flex; height: auto; + line-height: 1.2; + justify-content: space-between; + text-align: left; + white-space: normal; padding: $size-8; + width: 100%; } diff --git a/ui/app/styles/core/lists.scss b/ui/app/styles/core/lists.scss index 26e0da6a9f..bc9c6dacc5 100644 --- a/ui/app/styles/core/lists.scss +++ b/ui/app/styles/core/lists.scss @@ -4,7 +4,15 @@ &:before { font-size: $size-5; color: $white-ter; - content: '|'; + content: "|"; position: relative; } } + +.list-header { + margin: $size-9 $size-9 0; + color: $grey; + font-size: $size-8; + font-weight: $font-weight-semibold; + text-transform: uppercase; +} diff --git a/ui/app/styles/core/switch.scss b/ui/app/styles/core/switch.scss index 12e30d4a87..fcdfedc108 100644 --- a/ui/app/styles/core/switch.scss +++ b/ui/app/styles/core/switch.scss @@ -1,27 +1,35 @@ .switch[type="checkbox"] { &.is-small { + label { - font-size: $size-9; + font-size: $size-8; font-weight: bold; - padding-left: $size-9 * 2.5; + padding-left: $size-8 * 2.5; margin: 0 0.25rem; &::before { - top: $size-9 / 5; - height: $size-9; - width: $size-9 * 2; + top: $size-8 / 5; + height: $size-8; + width: $size-8 * 2; } &::after { - width: $size-9 * 0.68; - height: $size-9 * 0.68; - left: .15rem; - top: $size-9 / 2.5; + width: $size-8 * 0.8; + height: $size-8 * 0.8; + transform: translateX(0.15rem); + left: 0; + top: $size-8/ 4; } } &:checked + label::after { - left: ($size-9 * 2) - ($size-9 * 0.9); + left: 0; + transform: translateX(($size-8 * 2) - ($size-8 * 0.94)); } } } +.switch[type="checkbox"].is-small + label::after { + will-change: left; +} .switch[type="checkbox"]:focus + label { box-shadow: 0 0 1px $blue; } +.switch[type="checkbox"].is-success:checked + label::before { + background: $blue; +} diff --git a/ui/app/styles/utils/_bulma_variables.scss b/ui/app/styles/utils/_bulma_variables.scss index af61e9b863..16852bf611 100644 --- a/ui/app/styles/utils/_bulma_variables.scss +++ b/ui/app/styles/utils/_bulma_variables.scss @@ -84,3 +84,7 @@ $box-link-hover-shadow: 0 0 0 1px $grey-light; // animations $speed: 150ms; $speed-slow: $speed * 2; + +// Wizard +$wizard-progress-bar-height: 6px; +$wizard-progress-check-size: 16px; diff --git a/ui/app/templates/components/block-empty.hbs b/ui/app/templates/components/block-empty.hbs new file mode 100644 index 0000000000..d0f7e402f7 --- /dev/null +++ b/ui/app/templates/components/block-empty.hbs @@ -0,0 +1,11 @@ +
+ {{yield}} +
+{{capitalize currentFeatureProgress.feature}}
+ {{currentFeatureProgress.text}} + {{/if}} +