From 21af2046838cb88930b60babd52296adae7c6581 Mon Sep 17 00:00:00 2001 From: Matthew Irish Date: Thu, 16 Aug 2018 12:48:24 -0500 Subject: [PATCH] UI namespaces (#5119) * add namespace sidebar item * depend on ember-inflector directly * list-view and list-item components * fill out components and render empty namespaces page * list namespaces in access * add menu contextual component to list item * popup contextual component * full crud for namespaces * add namespaces service and picker component * split application and vault.cluster templates and controllers, add namespace query param, add namespace-picker to vault.namespace template * remove usage of href-to * remove ember-href-to from deps * add ember-responsive * start styling the picker and link to appropriate namespaces, use ember-responsive to render picker in different places based on the breakpoint * get query param working and save ns to authdata when authenticating, feed through ns in application adapter * move to observer on the controller for setting state on the service * set state in the beforeModel hook and clear the ember data model cache * nav to secrets on change and make error handling more resilient utilizing the method that atlas does to eagerly update URLs * add a list of sys endpoints in a helper * hide header elements if not in the root namespace * debounce namespace input on auth, fix 404 for auth method fetch, move auth method fetch to a task on the auth-form component and refretch on namespace change * fix display of supported engines and exclusion of sys and identity engines * don't fetch replication status if you're in a non-root namespace * hide seal sub-menu if not in the root namespace * don't autocomplete auth form inputs * always send some requests to the root namespace * use methodType and engineType instead of type in case there it is ns_ prefixed * use sys/internal/ui/namespaces to fetch the list in the dropdown * don't use model for namespace picker and always make the request to the token namespace * fix header handling for fetch calls * use namespace-reminder component on creation and edit forms throughout the application * add namespace-reminder to the console * add flat * add deepmerge for creating the tree in the menu * delayed rendering for animation timing * design and code feedback on the first round * white text in the namespace picker * fix namespace picker issues with root keys * separate path-to-tree * add tests for path-to-tree util * hide picker if you're in the root ns and you can't access other namespaces * show error message if you enter invalid characters for namespace path * return a different model if we dont have the namespaces feature and show upgrade page * if a token has a namespace_path, use that as the root user namespace and transition them there on login * use token namespace for user, but use specified namespace to log in * always renew tokens in the token namespace * fix edition-badge test --- ui/app/adapters/application.js | 32 ++- ui/app/adapters/auth-method.js | 4 +- ui/app/adapters/cluster.js | 3 +- ui/app/adapters/namespace.js | 34 ++++ ui/app/adapters/pki-config.js | 6 +- ui/app/breakpoints.js | 7 + ui/app/components/auth-form.js | 82 +++++--- ui/app/components/console/command-input.js | 2 - ui/app/components/edit-form.js | 4 +- ui/app/components/home-link.js | 14 +- ui/app/components/list-item.js | 23 +++ ui/app/components/list-item/content.js | 5 + ui/app/components/list-item/popup-menu.js | 5 + ui/app/components/list-view.js | 14 ++ ui/app/components/mount-filter-config-list.js | 2 +- ui/app/components/namespace-link.js | 32 +++ ui/app/components/namespace-picker.js | 141 +++++++++++++ ui/app/components/namespace-reminder.js | 18 ++ ui/app/components/replication-mode-summary.js | 16 +- ui/app/components/secret-link.js | 20 +- ui/app/controllers/application.js | 37 +--- ui/app/controllers/vault/cluster.js | 61 +++++- .../vault/cluster/access/namespaces/create.js | 15 ++ ui/app/controllers/vault/cluster/auth.js | 17 +- .../vault/cluster/secrets/backends.js | 2 +- ui/app/controllers/vault/cluster/settings.js | 6 + ui/app/helpers/has-feature.js | 1 + ui/app/lib/path-to-tree.js | 51 +++++ ui/app/macros/lazy-capabilities.js | 13 ++ ui/app/mixins/cluster-route.js | 9 +- ui/app/models/auth-method.js | 19 +- ui/app/models/namespace.js | 22 ++ ui/app/models/secret-engine.js | 14 +- ui/app/router.js | 4 + ui/app/routes/application.js | 44 +++- ui/app/routes/vault/cluster.js | 36 +++- .../vault/cluster/access/namespaces/create.js | 16 ++ .../vault/cluster/access/namespaces/index.js | 24 +++ ui/app/routes/vault/cluster/auth.js | 22 +- .../vault/cluster/secrets/backend/list.js | 6 +- .../cluster/secrets/backend/secret-edit.js | 6 +- .../vault/cluster/settings/auth/configure.js | 2 +- ui/app/serializers/namespace.js | 24 +++ ui/app/services/auth.js | 46 ++++- ui/app/services/namespace.js | 40 ++++ ui/app/services/version.js | 1 + ui/app/styles/components/auth-form.scss | 14 ++ .../styles/components/namespace-picker.scss | 102 ++++++++++ .../styles/components/namespace-reminder.scss | 12 ++ ui/app/styles/components/splash-page.scss | 10 +- ui/app/styles/core.scss | 2 + ui/app/styles/core/buttons.scss | 11 + ui/app/styles/core/navbar.scss | 3 + ui/app/templates/application.hbs | 119 +---------- .../components/auth-config-form/config.hbs | 1 + .../components/auth-config-form/options.hbs | 1 + ui/app/templates/components/auth-form.hbs | 189 +++++++++--------- ui/app/templates/components/auth-info.hbs | 4 +- ui/app/templates/components/config-pki-ca.hbs | 7 +- ui/app/templates/components/config-pki.hbs | 2 + .../components/console/command-input.hbs | 43 ++-- .../components/control-group-success.hbs | 4 +- ui/app/templates/components/control-group.hbs | 10 +- ui/app/templates/components/edit-form.hbs | 10 +- ui/app/templates/components/form-field.hbs | 10 +- .../components/generate-credentials.hbs | 13 +- ui/app/templates/components/home-link.hbs | 7 + .../components/identity/edit-form.hbs | 11 +- .../components/identity/entity-nav.hbs | 20 +- .../identity/item-alias/alias-details.hbs | 6 +- .../components/identity/item-aliases.hbs | 6 +- .../components/identity/item-groups.hbs | 12 +- .../components/identity/item-members.hbs | 12 +- .../identity/item-parent-groups.hbs | 6 +- .../components/identity/item-policies.hbs | 6 +- .../components/identity/popup-alias.hbs | 8 +- .../components/identity/popup-policy.hbs | 8 +- .../templates/components/key-value-header.hbs | 4 +- ui/app/templates/components/list-item.hbs | 38 ++++ .../components/list-item/content.hbs | 1 + .../components/list-item/popup-menu.hbs | 7 + ui/app/templates/components/list-view.hbs | 19 ++ .../components/mount-backend-form.hbs | 1 + .../templates/components/namespace-link.hbs | 14 ++ .../templates/components/namespace-picker.hbs | 70 +++++++ .../components/namespace-reminder.hbs | 5 + .../templates/components/pki-cert-popup.hbs | 4 +- ui/app/templates/components/secret-link.hbs | 13 ++ .../components/secret-list-header.hbs | 19 +- ui/app/templates/components/section-tabs.hbs | 4 +- ui/app/templates/components/splash-page.hbs | 5 +- .../components/tool-actions-form.hbs | 1 - .../components/transit-key-action/datakey.hbs | 1 + .../components/transit-key-action/encrypt.hbs | 1 + .../components/transit-key-action/hmac.hbs | 1 + .../components/transit-key-action/rewrap.hbs | 1 + .../components/transit-key-action/sign.hbs | 1 + .../templates/partials/auth-form/github.hbs | 1 + ui/app/templates/partials/auth-form/token.hbs | 1 + .../replication/replication-mode-summary.hbs | 4 +- ui/app/templates/partials/role-aws/form.hbs | 3 +- .../partials/role-aws/popup-menu.hbs | 16 +- ui/app/templates/partials/role-pki/form.hbs | 3 +- .../partials/role-pki/popup-menu.hbs | 16 +- ui/app/templates/partials/role-ssh/form.hbs | 3 +- .../partials/role-ssh/popup-menu.hbs | 16 +- .../partials/secret-backend-settings/aws.hbs | 10 +- .../partials/secret-backend-settings/ssh.hbs | 1 + .../templates/partials/secret-form-create.hbs | 3 +- .../templates/partials/secret-form-edit.hbs | 25 ++- ui/app/templates/partials/status/cluster.hbs | 2 +- ui/app/templates/partials/tools/hash.hbs | 1 + ui/app/templates/partials/tools/lookup.hbs | 2 + ui/app/templates/partials/tools/random.hbs | 1 + ui/app/templates/partials/tools/rewrap.hbs | 2 + ui/app/templates/partials/tools/unwrap.hbs | 2 + ui/app/templates/partials/tools/wrap.hbs | 2 + .../partials/transit-form-create.hbs | 3 +- .../templates/partials/transit-form-edit.hbs | 3 +- ui/app/templates/partials/userpass-form.hbs | 2 + ui/app/templates/vault/cluster.hbs | 142 ++++++++++++- ui/app/templates/vault/cluster/access.hbs | 21 +- .../cluster/access/identity/aliases/index.hbs | 11 +- .../cluster/access/identity/aliases/show.hbs | 12 +- .../vault/cluster/access/identity/index.hbs | 19 +- .../vault/cluster/access/identity/show.hbs | 20 +- .../vault/cluster/access/leases/list.hbs | 10 +- .../vault/cluster/access/method/section.hbs | 2 +- .../vault/cluster/access/methods.hbs | 18 +- .../vault/cluster/access/namespaces.hbs | 1 + .../cluster/access/namespaces/create.hbs | 24 +++ .../vault/cluster/access/namespaces/index.hbs | 41 ++++ ui/app/templates/vault/cluster/auth.hbs | 29 ++- ui/app/templates/vault/cluster/policies.hbs | 12 +- .../vault/cluster/policies/create.hbs | 7 +- .../vault/cluster/policies/index.hbs | 21 +- ui/app/templates/vault/cluster/policy.hbs | 12 +- .../templates/vault/cluster/policy/edit.hbs | 22 +- .../templates/vault/cluster/policy/show.hbs | 4 +- .../vault/cluster/replication/mode.hbs | 12 +- .../replication/mode/secondaries/index.hbs | 4 +- .../vault/cluster/secrets/backend/error.hbs | 8 +- .../vault/cluster/secrets/backend/sign.hbs | 13 +- .../vault/cluster/secrets/backends.hbs | 18 +- ui/app/templates/vault/cluster/settings.hbs | 13 +- .../cluster/settings/mount-secret-backend.hbs | 1 + ui/app/templates/vault/cluster/tools/tool.hbs | 10 +- ui/config/environment.js | 7 +- ui/package.json | 5 +- ui/tests/acceptance/transit-test.js | 5 +- .../integration/components/auth-form-test.js | 55 ++--- .../components/edition-badge-test.js | 2 +- ui/tests/unit/adapters/_adapter-needs.js | 9 + ui/tests/unit/adapters/capabilities-test.js | 3 +- ui/tests/unit/adapters/cluster-test.js | 3 +- ui/tests/unit/adapters/console-test.js | 3 +- .../adapters/identity/entity-alias-test.js | 3 +- .../adapters/identity/entity-merge-test.js | 3 +- .../unit/adapters/identity/entity-test.js | 3 +- .../adapters/identity/group-alias-test.js | 3 +- ui/tests/unit/adapters/identity/group-test.js | 3 +- ui/tests/unit/adapters/secret-engine-test.js | 3 +- ui/tests/unit/adapters/secret-test.js | 3 +- ui/tests/unit/adapters/secret-v2-test.js | 3 +- ui/tests/unit/adapters/tools-test.js | 3 +- ui/tests/unit/adapters/transit-key-test.js | 3 +- ui/tests/unit/lib/path-to-tree-test.js | 60 ++++++ ui/tests/unit/services/auth-test.js | 8 +- ui/yarn.lock | 32 ++- 169 files changed, 2013 insertions(+), 719 deletions(-) create mode 100644 ui/app/adapters/namespace.js create mode 100644 ui/app/breakpoints.js create mode 100644 ui/app/components/list-item.js create mode 100644 ui/app/components/list-item/content.js create mode 100644 ui/app/components/list-item/popup-menu.js create mode 100644 ui/app/components/list-view.js create mode 100644 ui/app/components/namespace-link.js create mode 100644 ui/app/components/namespace-picker.js create mode 100644 ui/app/components/namespace-reminder.js create mode 100644 ui/app/controllers/vault/cluster/access/namespaces/create.js create mode 100644 ui/app/controllers/vault/cluster/settings.js create mode 100644 ui/app/lib/path-to-tree.js create mode 100644 ui/app/models/namespace.js create mode 100644 ui/app/routes/vault/cluster/access/namespaces/create.js create mode 100644 ui/app/routes/vault/cluster/access/namespaces/index.js create mode 100644 ui/app/serializers/namespace.js create mode 100644 ui/app/services/namespace.js create mode 100644 ui/app/styles/components/namespace-picker.scss create mode 100644 ui/app/styles/components/namespace-reminder.scss create mode 100644 ui/app/templates/components/home-link.hbs create mode 100644 ui/app/templates/components/list-item.hbs create mode 100644 ui/app/templates/components/list-item/content.hbs create mode 100644 ui/app/templates/components/list-item/popup-menu.hbs create mode 100644 ui/app/templates/components/list-view.hbs create mode 100644 ui/app/templates/components/namespace-link.hbs create mode 100644 ui/app/templates/components/namespace-picker.hbs create mode 100644 ui/app/templates/components/namespace-reminder.hbs create mode 100644 ui/app/templates/components/secret-link.hbs create mode 100644 ui/app/templates/vault/cluster/access/namespaces.hbs create mode 100644 ui/app/templates/vault/cluster/access/namespaces/create.hbs create mode 100644 ui/app/templates/vault/cluster/access/namespaces/index.hbs create mode 100644 ui/tests/unit/adapters/_adapter-needs.js create mode 100644 ui/tests/unit/lib/path-to-tree-test.js diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index 00ca9d609..eec635537 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -1,12 +1,15 @@ import Ember from 'ember'; import DS from 'ember-data'; import fetch from 'fetch'; +import config from '../config/environment'; -const POLLING_URL_PATTERNS = ['sys/seal-status', 'sys/health', 'sys/replication/status']; +const { APP } = config; +const { POLLING_URLS, NAMESPACE_ROOT_URLS } = APP; const { inject, assign, set, RSVP } = Ember; export default DS.RESTAdapter.extend({ auth: inject.service(), + namespaceService: inject.service('namespace'), controlGroup: inject.service(), flashMessages: inject.service(), @@ -25,17 +28,26 @@ export default DS.RESTAdapter.extend({ return false; }, - _preRequest(url, options) { - const token = options.clientToken || this.get('auth.currentToken'); + addHeaders(url, options) { + let token = options.clientToken || this.get('auth.currentToken'); + let headers = {}; if (token && !options.unauthenticated) { - options.headers = assign(options.headers || {}, { - 'X-Vault-Token': token, - }); + headers['X-Vault-Token'] = token; if (options.wrapTTL) { - assign(options.headers, { 'X-Vault-Wrap-TTL': options.wrapTTL }); + headers['X-Vault-Wrap-TTL'] = options.wrapTTL; } } - const isPolling = POLLING_URL_PATTERNS.some(str => url.includes(str)); + let namespace = + typeof options.namespace === 'undefined' ? this.get('namespaceService.path') : options.namespace; + if (namespace && !NAMESPACE_ROOT_URLS.some(str => url.includes(str))) { + headers['X-Vault-Namespace'] = namespace; + } + options.headers = assign(options.headers || {}, headers); + }, + + _preRequest(url, options) { + this.addHeaders(url, options); + const isPolling = POLLING_URLS.some(str => url.includes(str)); if (!isPolling) { this.get('auth').setLastFetch(Date.now()); } @@ -87,8 +99,8 @@ export default DS.RESTAdapter.extend({ rawRequest(url, type, options = {}) { let opts = this._preRequest(url, options); return fetch(url, { - method: type | 'GET', - headers: opts.headers | {}, + method: type || 'GET', + headers: opts.headers || {}, }).then(response => { if (response.status >= 200 && response.status < 300) { return RSVP.resolve(response); diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js index 71ce75011..5f36329df 100644 --- a/ui/app/adapters/auth-method.js +++ b/ui/app/adapters/auth-method.js @@ -26,7 +26,9 @@ export default ApplicationAdapter.extend({ }; }) .catch(() => { - return []; + return { + data: {}, + }; }); } return this.ajax(this.url(), 'GET').catch(e => { diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index b8bc36eaa..97d2f2105 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -20,6 +20,7 @@ const REPLICATION_ENDPOINTS = { const REPLICATION_MODES = ['dr', 'performance']; export default ApplicationAdapter.extend({ version: inject.service(), + namespaceService: inject.service('namespace'), shouldBackgroundReloadRecord() { return true; }, @@ -28,7 +29,7 @@ export default ApplicationAdapter.extend({ health: this.health(), sealStatus: this.sealStatus().catch(e => e), }; - if (this.get('version.isEnterprise')) { + if (this.get('version.isEnterprise') && this.get('namespaceService.inRootNamespace')) { fetches.replicationStatus = this.replicationStatus().catch(e => e); } return Ember.RSVP.hash(fetches).then(({ health, sealStatus, replicationStatus }) => { diff --git a/ui/app/adapters/namespace.js b/ui/app/adapters/namespace.js new file mode 100644 index 000000000..d403841cd --- /dev/null +++ b/ui/app/adapters/namespace.js @@ -0,0 +1,34 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + pathForType() { + return 'namespaces'; + }, + urlForFindAll(modelName, snapshot) { + if (snapshot.adapterOptions && snapshot.adapterOptions.forUser) { + return `/${this.urlPrefix()}/internal/ui/namespaces`; + } + return `/${this.urlPrefix()}/namespaces?list=true`; + }, + + urlForCreateRecord(modelName, snapshot) { + let id = snapshot.attr('path'); + return this.buildURL(modelName, id); + }, + + createRecord(store, type, snapshot) { + let id = snapshot.attr('path'); + return this._super(...arguments).then(() => { + return { id }; + }); + }, + + findAll(store, type, sinceToken, snapshot) { + if (snapshot.adapterOptions && typeof snapshot.adapterOptions.namespace !== 'undefined') { + return this.ajax(this.urlForFindAll('namespace', snapshot), 'GET', { + namespace: snapshot.adapterOptions.namespace, + }); + } + return this._super(...arguments); + }, +}); diff --git a/ui/app/adapters/pki-config.js b/ui/app/adapters/pki-config.js index 63982b850..86ae8ae6c 100644 --- a/ui/app/adapters/pki-config.js +++ b/ui/app/adapters/pki-config.js @@ -65,9 +65,9 @@ export default ApplicationAdapter.extend({ return Ember.RSVP.hash({ backend: backendPath, id: this.id(backendPath), - der: this.rawRequest(derURL, { unauthenticated: true }).then(response => response.blob()), - pem: this.rawRequest(pemURL, { unauthenticated: true }).then(response => response.text()), - ca_chain: this.rawRequest(chainURL, { unauthenticated: true }).then(response => response.text()), + der: this.rawRequest(derURL, 'GET', { unauthenticated: true }).then(response => response.blob()), + pem: this.rawRequest(pemURL, 'GET', { unauthenticated: true }).then(response => response.text()), + ca_chain: this.rawRequest(chainURL, 'GET', { unauthenticated: true }).then(response => response.text()), }); }, diff --git a/ui/app/breakpoints.js b/ui/app/breakpoints.js new file mode 100644 index 000000000..6e89b27b0 --- /dev/null +++ b/ui/app/breakpoints.js @@ -0,0 +1,7 @@ +//https://github.com/jgthms/bulma/blob/6ad2e3df0589e5d6ff7a9c03ee1c78a546bedeaf/sass/utilities/initial-variables.sass#L48-L59 +//https://github.com/jgthms/bulma/blob/6ad2e3df0589e5d6ff7a9c03ee1c78a546bedeaf/sass/utilities/mixins.sass#L71-L130 +export default { + mobile: '(max-width: 768px)', + tablet: '(min-width: 769px)', + desktop: '(min-width: 1088px)', +}; diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index e02af6fdf..409318715 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -12,7 +12,6 @@ const DEFAULTS = { }; export default Ember.Component.extend(DEFAULTS, { - classNames: ['auth-form'], router: inject.service(), auth: inject.service(), flashMessages: inject.service(), @@ -24,6 +23,30 @@ export default Ember.Component.extend(DEFAULTS, { methods: null, cluster: null, redirectTo: null, + namespace: null, + // internal + oldNamespace: null, + didReceiveAttrs() { + this._super(...arguments); + let token = this.get('wrappedToken'); + let newMethod = this.get('selectedAuth'); + let oldMethod = this.get('oldSelectedAuth'); + + let ns = this.get('namespace'); + let oldNS = this.get('oldNamespace'); + if (oldNS === null || oldNS !== ns) { + this.get('fetchMethods').perform(); + } + this.set('oldNamespace', ns); + if (oldMethod && oldMethod !== newMethod) { + this.resetDefaults(); + } + this.set('oldSelectedAuth', newMethod); + + if (token) { + this.get('unwrapToken').perform(token); + } + }, didRender() { this._super(...arguments); @@ -35,10 +58,12 @@ export default Ember.Component.extend(DEFAULTS, { // this is here because we're changing the `with` attr and there's no way to short-circuit rendering, // so we'll just nav -> get new attrs -> re-render if (!this.get('selectedAuth') || (this.get('selectedAuth') && !this.get('selectedAuthBackend'))) { - this.get('router').replaceWith('vault.cluster.auth', this.get('cluster.name'), { + this.set('selectedAuth', this.firstMethod()); + this.get('router').replaceWith({ queryParams: { with: this.firstMethod(), wrappedToken: this.get('wrappedToken'), + namespace: this.get('namespace'), }, }); } @@ -50,40 +75,27 @@ export default Ember.Component.extend(DEFAULTS, { return get(firstMethod, 'path') || get(firstMethod, 'type'); }, - didReceiveAttrs() { - this._super(...arguments); - let token = this.get('wrappedToken'); - let newMethod = this.get('selectedAuth'); - let oldMethod = this.get('oldSelectedAuth'); - - if (oldMethod && oldMethod !== newMethod) { - this.resetDefaults(); - } - this.set('oldSelectedAuth', newMethod); - - if (token) { - this.get('unwrapToken').perform(token); - } - }, - resetDefaults() { this.setProperties(DEFAULTS); }, selectedAuthIsPath: computed.match('selectedAuth', /\/$/), selectedAuthBackend: Ember.computed( - 'allSupportedMethods', + 'methods', + 'methods.[]', 'selectedAuth', 'selectedAuthIsPath', function() { let methods = this.get('methods'); let selectedAuth = this.get('selectedAuth'); let keyIsPath = this.get('selectedAuthIsPath'); + if (!methods) { + return {}; + } if (keyIsPath) { return methods.findBy('path', selectedAuth); - } else { - return BACKENDS.findBy('type', selectedAuth); } + return BACKENDS.findBy('type', selectedAuth); } ), @@ -107,7 +119,7 @@ export default Ember.Component.extend(DEFAULTS, { hasMethodsWithPath: computed('methodsToShow', function() { return this.get('methodsToShow').isAny('path'); }), - methodsToShow: computed('methods', 'methods.[]', function() { + methodsToShow: computed('methods', function() { let methods = this.get('methods') || []; let shownMethods = methods.filter(m => BACKENDS.find(b => get(b, 'type').toLowerCase() === get(m, 'type').toLowerCase()) @@ -128,6 +140,24 @@ export default Ember.Component.extend(DEFAULTS, { } }), + fetchMethods: task(function*() { + let store = this.get('store'); + this.set('methods', null); + store.unloadAll('auth-method'); + try { + let methods = yield store.findAll('auth-method', { + adapterOptions: { + unauthenticated: true, + }, + }); + this.set('methods', methods); + } catch (e) { + this.set('error', `There was an error fetching auth methods: ${e.errors[0]}`); + } + }), + + showLoading: computed.or('fetchMethods.isRunning', 'unwrapToken.isRunning'), + handleError(e) { this.set('loading', false); let errors = e.errors.map(error => { @@ -149,9 +179,9 @@ export default Ember.Component.extend(DEFAULTS, { let targetRoute = this.get('redirectTo') || 'vault.cluster'; let backend = this.get('selectedAuthBackend') || {}; let backendMeta = BACKENDS.find( - b => get(b, 'type').toLowerCase() === get(backend, 'type').toLowerCase() + b => (get(b, 'type') || '').toLowerCase() === (get(backend, 'type') || '').toLowerCase() ); - let attributes = get(backendMeta, 'formAttributes'); + let attributes = get(backendMeta || {}, 'formAttributes') || {}; data = Ember.assign(data, this.getProperties(...attributes)); if (this.get('customPath') || get(backend, 'id')) { @@ -159,9 +189,9 @@ export default Ember.Component.extend(DEFAULTS, { } const clusterId = this.get('cluster.id'); this.get('auth').authenticate({ clusterId, backend: get(backend, 'type'), data }).then( - ({ isRoot }) => { + ({ isRoot, namespace }) => { this.set('loading', false); - const transition = this.get('router').transitionTo(targetRoute); + const transition = this.get('router').transitionTo(targetRoute, { queryParams: { namespace } }); if (isRoot) { transition.followRedirects().then(() => { this.get('flashMessages').warning( diff --git a/ui/app/components/console/command-input.js b/ui/app/components/console/command-input.js index d7907d3ab..3e9d59a5e 100644 --- a/ui/app/components/console/command-input.js +++ b/ui/app/components/console/command-input.js @@ -2,8 +2,6 @@ import Ember from 'ember'; import keys from 'vault/lib/keycodes'; export default Ember.Component.extend({ - 'data-test-component': 'console/command-input', - classNames: 'console-ui-input', onExecuteCommand() {}, onFullscreen() {}, onValueUpdate() {}, diff --git a/ui/app/components/edit-form.js b/ui/app/components/edit-form.js index 2a62487a8..caf7f76f4 100644 --- a/ui/app/components/edit-form.js +++ b/ui/app/components/edit-form.js @@ -11,12 +11,14 @@ export default Ember.Component.extend({ successMessage: 'Saved!', deleteSuccessMessage: 'Deleted!', deleteButtonText: 'Delete', + saveButtonText: 'Save', + cancelLink: null, /* * @param Function * @public * - * Optional param to call a function upon successfully saving an entity + * Optional param to call a function upon successfully saving a model */ onSave: () => {}, diff --git a/ui/app/components/home-link.js b/ui/app/components/home-link.js index 7eab8f799..98d8964f5 100644 --- a/ui/app/components/home-link.js +++ b/ui/app/components/home-link.js @@ -1,18 +1,8 @@ import Ember from 'ember'; -import hbs from 'htmlbars-inline-precompile'; -const { computed } = Ember; - -export default Ember.Component.extend({ - layout: hbs` - {{#if hasBlock}} - {{yield}} - {{else}} - {{text}} - {{/if}} - - `, +const { Component, computed } = Ember; +export default Component.extend({ tagName: '', text: computed(function() { diff --git a/ui/app/components/list-item.js b/ui/app/components/list-item.js new file mode 100644 index 000000000..709161895 --- /dev/null +++ b/ui/app/components/list-item.js @@ -0,0 +1,23 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +const { inject } = Ember; +export default Ember.Component.extend({ + flashMessages: inject.service(), + tagName: '', + linkParams: null, + componentName: null, + hasMenu: false, + + callMethod: task(function*(method, model, successMessage, failureMessage) { + let flash = this.get('flashMessages'); + try { + yield model[method](); + flash.success(successMessage); + } catch (e) { + let errString = e.errors.join(' '); + flash.danger(failureMessage + errString); + model.rollbackAttributes(); + } + }), +}); diff --git a/ui/app/components/list-item/content.js b/ui/app/components/list-item/content.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/list-item/content.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/list-item/popup-menu.js b/ui/app/components/list-item/popup-menu.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/list-item/popup-menu.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/list-view.js b/ui/app/components/list-view.js new file mode 100644 index 000000000..ca6784fdf --- /dev/null +++ b/ui/app/components/list-view.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +import { pluralize } from 'ember-inflector'; + +const { computed } = Ember; +export default Ember.Component.extend({ + tagName: '', + items: null, + itemNoun: 'item', + + emptyMessage: computed('itemNoun', function() { + let items = pluralize(this.get('itemNoun')); + return `There are currently no ${items}`; + }), +}); diff --git a/ui/app/components/mount-filter-config-list.js b/ui/app/components/mount-filter-config-list.js index 848d62c93..d05f7ac4e 100644 --- a/ui/app/components/mount-filter-config-list.js +++ b/ui/app/components/mount-filter-config-list.js @@ -7,7 +7,7 @@ export default Ember.Component.extend({ mounts: null, // singleton mounts are not eligible for per-mount-filtering - singletonMountTypes: ['cubbyhole', 'system', 'token', 'identity'], + singletonMountTypes: ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity'], actions: { addOrRemovePath(path, e) { diff --git a/ui/app/components/namespace-link.js b/ui/app/components/namespace-link.js new file mode 100644 index 000000000..adad03d1c --- /dev/null +++ b/ui/app/components/namespace-link.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; + +const { Component, computed, inject } = Ember; + +export default Component.extend({ + namespaceService: inject.service('namespace'), + currentNamespace: computed.alias('namespaceService.path'), + + tagName: '', + //public api + targetNamespace: null, + showLastSegment: false, + + normalizedNamespace: computed('targetNamespace', function() { + let ns = this.get('targetNamespace'); + return (ns || '').replace(/\.+/g, '/').replace('☃', '.'); + }), + + namespaceDisplay: computed('normalizedNamespace', 'showLastSegment', function() { + let ns = this.get('normalizedNamespace'); + let showLastSegment = this.get('showLastSegment'); + let parts = ns.split('/'); + if (ns === '') { + return 'root'; + } + return showLastSegment ? parts[parts.length - 1] : ns; + }), + + isCurrentNamespace: computed('targetNamespace', 'currentNamespace', function() { + return this.get('currentNamespace') === this.get('targetNamespace'); + }), +}); diff --git a/ui/app/components/namespace-picker.js b/ui/app/components/namespace-picker.js new file mode 100644 index 000000000..da8721dfa --- /dev/null +++ b/ui/app/components/namespace-picker.js @@ -0,0 +1,141 @@ +import Ember from 'ember'; +import keyUtils from 'vault/lib/key-utils'; +import pathToTree from 'vault/lib/path-to-tree'; +import { task, timeout } from 'ember-concurrency'; + +const { ancestorKeysForKey } = keyUtils; +const { Component, computed, inject } = Ember; +const DOT_REPLACEMENT = '☃'; +const ANIMATION_DURATION = 250; + +export default Component.extend({ + tagName: '', + namespaceService: inject.service('namespace'), + auth: inject.service(), + namespace: null, + + init() { + this._super(...arguments); + this.get('namespaceService.findNamespacesForUser').perform(); + }, + + didReceiveAttrs() { + this._super(...arguments); + + let ns = this.get('namespace'); + let oldNS = this.get('oldNamespace'); + if (!oldNS || ns !== oldNS) { + this.get('setForAnimation').perform(); + } + this.set('oldNamespace', ns); + }, + + setForAnimation: task(function*() { + let leaves = this.get('menuLeaves'); + let lastLeaves = this.get('lastMenuLeaves'); + if (!lastLeaves) { + this.set('lastMenuLeaves', leaves); + yield timeout(0); + return; + } + let isAdding = leaves.length > lastLeaves.length; + let changedLeaf = (isAdding ? leaves : lastLeaves).get('lastObject'); + this.set('isAdding', isAdding); + this.set('changedLeaf', changedLeaf); + + // if we're adding we want to render immediately an animate it in + // if we're not adding, we need time to move the item out before + // a rerender removes it + if (isAdding) { + this.set('lastMenuLeaves', leaves); + yield timeout(0); + return; + } + yield timeout(ANIMATION_DURATION); + this.set('lastMenuLeaves', leaves); + }).drop(), + + isAnimating: computed.alias('setForAnimation.isRunning'), + + namespacePath: computed.alias('namespaceService.path'), + + // this is an array of namespace paths that the current user + // has access to + accessibleNamespaces: computed.alias('namespaceService.accessibleNamespaces'), + inRootNamespace: computed.alias('namespaceService.inRootNamespace'), + + namespaceTree: computed('accessibleNamespaces', function() { + let nsList = this.get('accessibleNamespaces'); + + if (!nsList) { + return []; + } + return pathToTree(nsList); + }), + + maybeAddRoot(leaves) { + let userRoot = this.get('auth.authData.userRootNamespace'); + if (userRoot === '') { + leaves.unshift(''); + } + + return leaves.uniq(); + }, + + pathToLeaf(path) { + // dots are allowed in namespace paths + // so we need to preserve them, and replace slashes with dots + // in order to use Ember's get function on the namespace tree + // to pull out the correct level + return ( + path + // trim trailing slash + .replace(/\/$/, '') + // replace dots with snowman + .replace(/\.+/g, DOT_REPLACEMENT) + // replace slash with dots + .replace(/\/+/g, '.') + ); + }, + + // an array that keeps track of what additional panels to render + // on the menu stack + // if you're in 'foo/bar/baz', + // this array will be: ['foo', 'foo.bar', 'foo.bar.baz'] + // the template then iterates over this, and does Ember.get(namespaceTree, leaf) + // to render the nodes of each leaf + + // gets set as 'lastMenuLeaves' in the ember concurrency task above + menuLeaves: computed('namespacePath', 'namespaceTree', function() { + let ns = this.get('namespacePath'); + let leaves = ancestorKeysForKey(ns) || []; + leaves.push(ns); + leaves = this.maybeAddRoot(leaves); + + leaves = leaves.map(this.pathToLeaf); + return leaves; + }), + + // the nodes at the root of the namespace tree + // these will get rendered as the bottom layer + rootLeaves: computed('namespaceTree', function() { + let tree = this.get('namespaceTree'); + let leaves = Object.keys(tree); + return leaves; + }), + + currentLeaf: computed.alias('lastMenuLeaves.lastObject'), + canAccessMultipleNamespaces: computed.gt('accessibleNamespaces.length', 1), + isUserRootNamespace: computed('auth.authData.userRootNamespace', 'namespacePath', function() { + return this.get('auth.authData.userRootNamespace') === this.get('namespacePath'); + }), + + namespaceDisplay: computed('namespacePath', 'accessibleNamespaces', 'accessibleNamespaces.[]', function() { + let namespace = this.get('namespacePath'); + if (namespace === '') { + return ''; + } + let parts = namespace.split('/'); + return parts[parts.length - 1]; + }), +}); diff --git a/ui/app/components/namespace-reminder.js b/ui/app/components/namespace-reminder.js new file mode 100644 index 000000000..7659d9b62 --- /dev/null +++ b/ui/app/components/namespace-reminder.js @@ -0,0 +1,18 @@ +import Ember from 'ember'; + +const { Component, inject, computed } = Ember; + +export default Component.extend({ + namespace: inject.service(), + showMessage: computed.not('namespace.inRootNamespace'), + //public API + noun: null, + mode: 'edit', + modeVerb: computed(function() { + let mode = this.get('mode'); + if (!mode) { + return ''; + } + return mode.endsWith('e') ? `${mode}d` : `${mode}ed`; + }), +}); diff --git a/ui/app/components/replication-mode-summary.js b/ui/app/components/replication-mode-summary.js index 5035f459a..c5258ce84 100644 --- a/ui/app/components/replication-mode-summary.js +++ b/ui/app/components/replication-mode-summary.js @@ -1,6 +1,5 @@ import Ember from 'ember'; -import { hrefTo } from 'vault/helpers/href-to'; -const { computed, get, getProperties } = Ember; +const { computed, get, getProperties, Component, inject } = Ember; const replicationAttr = function(attr) { return computed('mode', `cluster.{dr,performance}.${attr}`, function() { @@ -8,8 +7,10 @@ const replicationAttr = function(attr) { return get(cluster, `${mode}.${attr}`); }); }; -export default Ember.Component.extend({ - version: Ember.inject.service(), +export default Component.extend({ + version: inject.service(), + router: inject.service(), + namespace: inject.service(), classNames: ['level', 'box-label'], classNameBindings: ['isMenu:is-mobile'], attributeBindings: ['href', 'target'], @@ -22,7 +23,12 @@ export default Ember.Component.extend({ return 'https://www.hashicorp.com/products/vault'; } if (this.get('replicationEnabled') || display === 'menu') { - return hrefTo(this, 'vault.cluster.replication.mode.index', this.get('cluster.name'), mode); + return this.get('router').urlFor( + 'vault.cluster.replication.mode.index', + this.get('cluster.name'), + mode, + { queryParams: { namespace: this.get('namespace.path') } } + ); } return null; }), diff --git a/ui/app/components/secret-link.js b/ui/app/components/secret-link.js index 70fa30827..4020be486 100644 --- a/ui/app/components/secret-link.js +++ b/ui/app/components/secret-link.js @@ -1,7 +1,5 @@ import Ember from 'ember'; -import hbs from 'htmlbars-inline-precompile'; -import { hrefTo } from 'vault/helpers/href-to'; -const { computed } = Ember; +const { computed, Component } = Ember; export function linkParams({ mode, secret, queryParams }) { let params; @@ -20,7 +18,8 @@ export function linkParams({ mode, secret, queryParams }) { return params; } -export default Ember.Component.extend({ +export default Component.extend({ + tagName: '', mode: 'list', secret: null, @@ -28,16 +27,7 @@ export default Ember.Component.extend({ ariaLabel: null, linkParams: computed('mode', 'secret', 'queryParams', function() { - return linkParams(this.getProperties('mode', 'secret', 'queryParams')); + let data = this.getProperties('mode', 'secret', 'queryParams'); + return linkParams(data); }), - - attributeBindings: ['href', 'aria-label:ariaLabel'], - - href: computed('linkParams', function() { - return hrefTo(this, ...this.get('linkParams')); - }), - - layout: hbs`{{yield}}`, - - tagName: 'a', }); diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js index 353649234..93d3fc949 100644 --- a/ui/app/controllers/application.js +++ b/ui/app/controllers/application.js @@ -1,41 +1,16 @@ import Ember from 'ember'; import config from '../config/environment'; -const { computed, inject } = Ember; -export default Ember.Controller.extend({ +const { Controller, computed, inject } = Ember; +export default Controller.extend({ env: config.environment, auth: inject.service(), - vaultVersion: inject.service('version'), - console: inject.service(), - consoleOpen: computed.alias('console.isOpen'), + store: inject.service(), activeCluster: computed('auth.activeCluster', function() { - return this.store.peekRecord('cluster', this.get('auth.activeCluster')); + return this.get('store').peekRecord('cluster', this.get('auth.activeCluster')); }), - activeClusterName: computed('auth.activeCluster', function() { - const activeCluster = this.store.peekRecord('cluster', this.get('auth.activeCluster')); + activeClusterName: computed('activeCluster', function() { + const activeCluster = this.get('activeCluster'); return activeCluster ? activeCluster.get('name') : null; }), - showNav: computed( - 'activeClusterName', - 'auth.currentToken', - 'activeCluster.dr.isSecondary', - 'activeCluster.{needsInit,sealed}', - function() { - if ( - this.get('activeCluster.dr.isSecondary') || - this.get('activeCluster.needsInit') || - this.get('activeCluster.sealed') - ) { - return false; - } - if (this.get('activeClusterName') && this.get('auth.currentToken')) { - return true; - } - } - ), - actions: { - toggleConsole() { - this.toggleProperty('consoleOpen'); - }, - }, }); diff --git a/ui/app/controllers/vault/cluster.js b/ui/app/controllers/vault/cluster.js index 141426150..6e88764d9 100644 --- a/ui/app/controllers/vault/cluster.js +++ b/ui/app/controllers/vault/cluster.js @@ -1,8 +1,63 @@ import Ember from 'ember'; -const { inject, Controller } = Ember; - +const { Controller, computed, observer, inject } = Ember; export default Controller.extend({ auth: inject.service(), - version: inject.service(), + store: inject.service(), + media: inject.service(), + namespaceService: inject.service('namespace'), + + vaultVersion: inject.service('version'), + console: inject.service(), + + queryParams: [ + { + namespaceQueryParam: { + scope: 'controller', + as: 'namespace', + }, + }, + ], + + namespaceQueryParam: '', + + onQPChange: observer('namespaceQueryParam', function() { + this.get('namespaceService').setNamespace(this.get('namespaceQueryParam')); + }), + + consoleOpen: computed.alias('console.isOpen'), + + activeCluster: computed('auth.activeCluster', function() { + return this.get('store').peekRecord('cluster', this.get('auth.activeCluster')); + }), + + activeClusterName: computed('activeCluster', function() { + const activeCluster = this.get('activeCluster'); + return activeCluster ? activeCluster.get('name') : null; + }), + + showNav: computed( + 'activeClusterName', + 'auth.currentToken', + 'activeCluster.dr.isSecondary', + 'activeCluster.{needsInit,sealed}', + function() { + if ( + this.get('activeCluster.dr.isSecondary') || + this.get('activeCluster.needsInit') || + this.get('activeCluster.sealed') + ) { + return false; + } + if (this.get('activeClusterName') && this.get('auth.currentToken')) { + return true; + } + } + ), + + actions: { + toggleConsole() { + this.toggleProperty('consoleOpen'); + }, + }, }); diff --git a/ui/app/controllers/vault/cluster/access/namespaces/create.js b/ui/app/controllers/vault/cluster/access/namespaces/create.js new file mode 100644 index 000000000..141bfce74 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/namespaces/create.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +const { inject, Controller } = Ember; +export default Controller.extend({ + namespaceService: inject.service('namespace'), + actions: { + onSave({ saveType }) { + if (saveType === 'save') { + // fetch new namespaces for the namespace picker + this.get('namespaceService.findNamespacesForUser').perform(); + return this.transitionToRoute('vault.cluster.access.namespaces.index'); + } + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 0e1ca62b9..32e79e8a0 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -1,9 +1,20 @@ import Ember from 'ember'; +import { task, timeout } from 'ember-concurrency'; -export default Ember.Controller.extend({ - vaultController: Ember.inject.controller('vault'), +const { inject, computed, Controller } = Ember; +export default Controller.extend({ + vaultController: inject.controller('vault'), + clusterController: inject.controller('vault.cluster'), + namespaceService: inject.service('namespace'), + namespaceQueryParam: computed.alias('clusterController.namespaceQueryParam'), queryParams: [{ authMethod: 'with' }], - wrappedToken: Ember.computed.alias('vaultController.wrappedToken'), + wrappedToken: computed.alias('vaultController.wrappedToken'), authMethod: '', redirectTo: null, + + updateNamespace: task(function*(value) { + yield timeout(200); + this.get('namespaceService').setNamespace(value, true); + this.set('namespaceQueryParam', value); + }).restartable(), }); diff --git a/ui/app/controllers/vault/cluster/secrets/backends.js b/ui/app/controllers/vault/cluster/secrets/backends.js index 31195d10f..8b5b024b9 100644 --- a/ui/app/controllers/vault/cluster/secrets/backends.js +++ b/ui/app/controllers/vault/cluster/secrets/backends.js @@ -8,7 +8,7 @@ export default Controller.extend({ supportedBackends: computed('displayableBackends', 'displayableBackends.[]', function() { return (this.get('displayableBackends') || []) - .filter(backend => LINKED_BACKENDS.includes(backend.get('type'))) + .filter(backend => LINKED_BACKENDS.includes(backend.get('engineType'))) .sortBy('id'); }), diff --git a/ui/app/controllers/vault/cluster/settings.js b/ui/app/controllers/vault/cluster/settings.js new file mode 100644 index 000000000..74a5a3625 --- /dev/null +++ b/ui/app/controllers/vault/cluster/settings.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +const { inject, Controller } = Ember; +export default Controller.extend({ + namespaceService: inject.service('namespace'), +}); diff --git a/ui/app/helpers/has-feature.js b/ui/app/helpers/has-feature.js index f260b7fd7..bc463367e 100644 --- a/ui/app/helpers/has-feature.js +++ b/ui/app/helpers/has-feature.js @@ -11,6 +11,7 @@ const FEATURES = [ 'GCP CKMS Autounseal', 'Seal Wrapping', 'Control Groups', + 'Namespaces', ]; export function hasFeature(featureName, features) { diff --git a/ui/app/lib/path-to-tree.js b/ui/app/lib/path-to-tree.js new file mode 100644 index 000000000..254cd7fc2 --- /dev/null +++ b/ui/app/lib/path-to-tree.js @@ -0,0 +1,51 @@ +import flat from 'flat'; +import deepmerge from 'deepmerge'; + +const { unflatten } = flat; +const DOT_REPLACEMENT = '☃'; + +//function that takes a list of path and returns a deeply nested object +//representing a tree of all of those paths +// +// +// given ["foo", "bar", "foo1", "foo/bar", "foo/baz", "foo/bar/baz"] +// +// returns { +// bar: null, +// foo: { +// bar: { +// baz: null +// }, +// baz: null, +// }, +// foo1: null, +// } +export default function(paths) { + // first sort the list by length, then alphanumeric + let list = paths.slice(0).sort((a, b) => b.length - a.length || b.localeCompare(a)); + // then reduce to an array + // and we remove all of the items that have a string + // that starts with the same prefix from the list + // so if we have "foo/bar/baz", both "foo" and "foo/bar" + // won't be included in the list + let tree = list.reduce((accumulator, ns) => { + let nsWithPrefix = accumulator.find(path => path.startsWith(ns)); + // we need to make sure it's a match for the full path part + let isFullMatch = nsWithPrefix && nsWithPrefix.charAt(ns.length) === '/'; + if (!isFullMatch) { + accumulator.push(ns); + } + return accumulator; + }, []); + + // after the reduction we're left with an array that contains + // strings that represent the longest branches + // we'll replace the dots in the paths, then expand the path + // to a nested object that we can then query with Ember.get + return deepmerge.all( + tree.map(p => { + p = p.replace(/\.+/g, DOT_REPLACEMENT); + return unflatten({ [p]: null }, { delimiter: '/' }); + }) + ); +} diff --git a/ui/app/macros/lazy-capabilities.js b/ui/app/macros/lazy-capabilities.js index 108d66a45..7756f87f4 100644 --- a/ui/app/macros/lazy-capabilities.js +++ b/ui/app/macros/lazy-capabilities.js @@ -1,3 +1,16 @@ +// usage: +// +// import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +// +// export default DS.Model.extend({ +// //pass the template string as the first arg, and be sure to use '' around the +// //paramerters that get interpolated in the string - that's how the template function +// //knows where to put each value +// zeroAddressPath: lazyCapabilities(apiPath`${'id'}/config/zeroaddress`, 'id'), +// +// }); +// + import { queryRecord } from 'ember-computed-query'; export function apiPath(strings, ...keys) { diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js index e04288ae4..05d2aa064 100644 --- a/ui/app/mixins/cluster-route.js +++ b/ui/app/mixins/cluster-route.js @@ -1,6 +1,6 @@ import Ember from 'ember'; -const { get } = Ember; +const { get, inject, Mixin, RSVP } = Ember; const INIT = 'vault.cluster.init'; const UNSEAL = 'vault.cluster.unseal'; const AUTH = 'vault.cluster.auth'; @@ -9,15 +9,16 @@ const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote'; export { INIT, UNSEAL, AUTH, CLUSTER, DR_REPLICATION_SECONDARY }; -export default Ember.Mixin.create({ - auth: Ember.inject.service(), +export default Mixin.create({ + auth: inject.service(), transitionToTargetRoute() { const targetRoute = this.targetRouteName(); if (targetRoute && targetRoute !== this.routeName) { return this.transitionTo(targetRoute); } - return Ember.RSVP.resolve(); + + return RSVP.resolve(); }, beforeModel() { diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index 980af5ceb..0dcb063e8 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -5,6 +5,7 @@ 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'; const { attr, hasMany } = DS; const { computed } = Ember; @@ -27,6 +28,11 @@ export default DS.Model.extend({ defaultValue: METHODS[0].value, possibleValues: METHODS, }), + // 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() { + return this.get('type').replace(/^ns_/, ''); + }), description: attr('string', { editType: 'textarea', }), @@ -108,17 +114,8 @@ export default DS.Model.extend({ 'id', 'configPathTmpl' ), - deletePath: queryRecord( - 'capabilities', - context => { - const { id } = context.get('id'); - return { - id: `sys/auth/${id}`, - }; - }, - 'id' - ), - canDisable: computed.alias('deletePath.canDelete'), + deletePath: lazyCapabilities(apiPath`sys/auth/${'id'}`, 'id'), + canDisable: computed.alias('deletePath.canDelete'), canEdit: computed.alias('configPath.canUpdate'), }); diff --git a/ui/app/models/namespace.js b/ui/app/models/namespace.js new file mode 100644 index 000000000..3329cb910 --- /dev/null +++ b/ui/app/models/namespace.js @@ -0,0 +1,22 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const { attr } = DS; +const { computed } = Ember; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +export default DS.Model.extend({ + path: attr('string', { + validationAttr: 'pathIsValid', + invalidMessage: 'You have entered and invalid path please only include letters, numbers, -, ., and _.', + }), + pathIsValid: computed('path', function() { + return this.get('path') && this.get('path').match(/^[\w\d-.]+$/g); + }), + description: attr('string', { + editType: 'textarea', + }), + fields: computed(function() { + return expandAttributeMeta(this, ['path']); + }), +}); diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index a6d7cf23d..93d2c28a3 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -23,8 +23,8 @@ export default DS.Model.extend({ local: attr('boolean'), sealWrap: attr('boolean'), - modelTypeForKV: computed('type', 'options.version', function() { - let type = this.get('type'); + modelTypeForKV: computed('engineType', 'options.version', function() { + let type = this.get('engineType'); let version = this.get('options.version'); let modelType = 'secret'; if ((type === 'kv' || type === 'generic') && version === 2) { @@ -48,8 +48,14 @@ export default DS.Model.extend({ return expandAttributeMeta(this, this.get('formFields')); }), - shouldIncludeInList: computed('type', function() { - return !LIST_EXCLUDED_BACKENDS.includes(this.get('type')); + // 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() { + return (this.get('type') || '').replace(/^ns_/, ''); + }), + + shouldIncludeInList: computed('engineType', function() { + return !LIST_EXCLUDED_BACKENDS.includes(this.get('engineType')); }), localDisplay: Ember.computed('local', function() { diff --git a/ui/app/router.js b/ui/app/router.js index 944d3fef4..3cd78b590 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -66,6 +66,10 @@ Router.map(function() { }); this.route('control-groups'); this.route('control-group-accessor', { path: '/control-groups/:accessor' }); + this.route('namespaces', function() { + this.route('index', { path: '/' }); + this.route('create'); + }); }); this.route('secrets', function() { this.route('backends', { path: '/' }); diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 598dc038c..6c026e6de 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -4,19 +4,55 @@ import ControlGroupError from 'vault/lib/control-group-error'; const { inject } = Ember; export default Ember.Route.extend({ controlGroup: inject.service(), + routing: inject.service('router'), + namespaceService: inject.service('namespace'), actions: { willTransition() { window.scrollTo(0, 0); }, - error(err, transition) { + error(error, transition) { let controlGroup = this.get('controlGroup'); - if (err instanceof ControlGroupError) { - return controlGroup.handleError(err, transition); + if (error instanceof ControlGroupError) { + return controlGroup.handleError(error, transition); } - if (err.path === '/v1/sys/wrapping/unwrap') { + if (error.path === '/v1/sys/wrapping/unwrap') { controlGroup.unmarkTokenForUnwrap(); } + + let router = this.get('routing'); + let errorURL = transition.intent.url; + let { name, contexts, queryParams } = transition.intent; + + // If the transition is internal to Ember, we need to generate the URL + // from the route parameters ourselves + if (!errorURL) { + try { + errorURL = router.urlFor(name, ...(contexts || []), { queryParams }); + } catch (e) { + // If this fails, something weird is happening with URL transitions + errorURL = null; + } + } + // because we're using rootURL, we need to trim this from the front to get + // the ember-routeable url + if (errorURL) { + errorURL = errorURL.replace('/ui', ''); + } + + error.errorURL = errorURL; + + // if we have queryParams, update the namespace so that the observer can fire on the controller + if (queryParams) { + this.controllerFor('vault.cluster').set('namespaceQueryParam', queryParams.namespace || ''); + } + + // Assuming we have a URL, push it into browser history and update the + // location bar for the user + if (errorURL) { + router.get('location').setURL(errorURL); + } + return true; }, }, diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js index a099784c9..4ee9f1012 100644 --- a/ui/app/routes/vault/cluster.js +++ b/ui/app/routes/vault/cluster.js @@ -3,14 +3,22 @@ import ClusterRoute from 'vault/mixins/cluster-route'; import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; const POLL_INTERVAL_MS = 10000; -const { inject } = Ember; +const { inject, Route, getOwner } = Ember; -export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, { +export default Route.extend(ModelBoundaryRoute, ClusterRoute, { + namespaceService: inject.service('namespace'), version: inject.service(), store: inject.service(), auth: inject.service(), - currentCluster: Ember.inject.service(), + currentCluster: inject.service(), modelTypes: ['node', 'secret', 'secret-engine'], + globalNamespaceModels: ['node', 'cluster'], + + queryParams: { + namespaceQueryParam: { + refreshModel: true, + }, + }, getClusterId(params) { const { cluster_name } = params; @@ -18,8 +26,24 @@ export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, { return cluster ? cluster.get('id') : null; }, + clearNonGlobalModels() { + // this method clears all of the ember data cached models except + // the model types blacklisted in `globalNamespaceModels` + let store = this.store; + let modelsToKeep = this.get('globalNamespaceModels'); + for (let model of getOwner(this).lookup('data-adapter:main').getModelTypes()) { + let { name } = model; + if (modelsToKeep.includes(name)) { + return; + } + store.unloadAll(name); + } + }, + beforeModel() { const params = this.paramsFor(this.routeName); + this.clearNonGlobalModels(); + this.get('namespaceService').setNamespace(params.namespaceQueryParam); const id = this.getClusterId(params); if (id) { this.get('auth').setCluster(id); @@ -61,6 +85,12 @@ export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, { this.get('currentCluster').setCluster(model); this._super(...arguments); this.poll(); + + // Check that namespaces is enabled and if not, + // clear the namespace by transition to this route w/o it + if (this.get('namespaceService.path') && !this.get('version.hasNamespaces')) { + return this.transitionTo(this.routeName, { queryParams: { namespace: '' } }); + } return this.transitionToTargetRoute(); }, diff --git a/ui/app/routes/vault/cluster/access/namespaces/create.js b/ui/app/routes/vault/cluster/access/namespaces/create.js new file mode 100644 index 000000000..3fd4363d6 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/namespaces/create.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +import UnloadModel from 'vault/mixins/unload-model-route'; + +const { inject } = Ember; + +export default Ember.Route.extend(UnloadModel, { + version: inject.service(), + beforeModel() { + return this.get('version').fetchFeatures().then(() => { + return this._super(...arguments); + }); + }, + model() { + return this.get('version.hasNamespaces') ? this.store.createRecord('namespace') : null; + }, +}); diff --git a/ui/app/routes/vault/cluster/access/namespaces/index.js b/ui/app/routes/vault/cluster/access/namespaces/index.js new file mode 100644 index 000000000..8f1ae9b9a --- /dev/null +++ b/ui/app/routes/vault/cluster/access/namespaces/index.js @@ -0,0 +1,24 @@ +import Ember from 'ember'; +import UnloadModel from 'vault/mixins/unload-model-route'; + +const { inject } = Ember; + +export default Ember.Route.extend(UnloadModel, { + version: inject.service(), + beforeModel() { + this.store.unloadAll('namespace'); + return this.get('version').fetchFeatures().then(() => { + return this._super(...arguments); + }); + }, + model() { + return this.get('version.hasNamespaces') + ? this.store.findAll('namespace').catch(e => { + if (e.httpStatus === 404) { + return []; + } + throw e; + }) + : null; + }, +}); diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index 976d9a406..e28d66e7c 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -2,28 +2,18 @@ import ClusterRouteBase from './cluster-route-base'; import Ember from 'ember'; import config from 'vault/config/environment'; -const { RSVP, inject } = Ember; +const { inject } = Ember; export default ClusterRouteBase.extend({ flashMessages: inject.service(), + version: inject.service(), beforeModel() { - this.store.unloadAll('auth-method'); - return this._super(); + return this._super().then(() => { + return this.get('version').fetchFeatures(); + }); }, model() { - let cluster = this._super(...arguments); - return this.store - .findAll('auth-method', { - adapterOptions: { - unauthenticated: true, - }, - }) - .then(result => { - return RSVP.hash({ - cluster, - methods: result, - }); - }); + return this._super(...arguments); }, resetController(controller) { controller.set('wrappedToken', ''); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index b2f36c71a..812ba5550 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -22,7 +22,7 @@ export default Ember.Route.extend({ let { backend } = this.paramsFor('vault.cluster.secrets.backend'); let { secret } = this.paramsFor(this.routeName); let backendModel = this.store.peekRecord('secret-engine', backend); - let type = backendModel && backendModel.get('type'); + let type = backendModel && backendModel.get('engineType'); if (!type || !SUPPORTED_BACKENDS.includes(type)) { return this.transitionTo('vault.cluster.secrets'); } @@ -33,7 +33,7 @@ export default Ember.Route.extend({ getModelType(backend, tab) { let backendModel = this.store.peekRecord('secret-engine', backend); - let type = backendModel.get('type'); + let type = backendModel.get('engineType'); let types = { transit: 'transit-key', ssh: 'role-ssh', @@ -115,7 +115,7 @@ export default Ember.Route.extend({ backend, backendModel, baseKey: { id: secret }, - backendType: backendModel.get('type'), + backendType: backendModel.get('engineType'), }); if (!has404) { const pageFilter = secretParams.pageFilter; 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 9d399b493..fd5b9322d 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -6,7 +6,7 @@ export default Ember.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('type'); + let backendType = backendModel.get('engineType'); let version = backendModel.get('options.version'); let path; if (backendType === 'transit') { @@ -22,7 +22,7 @@ export default Ember.Route.extend(UnloadModelRoute, { }, backendType() { - return this.modelFor('vault.cluster.secrets.backend').get('type'); + return this.modelFor('vault.cluster.secrets.backend').get('engineType'); }, templateName: 'vault/cluster/secrets/backend/secretEditLayout', @@ -45,7 +45,7 @@ export default Ember.Route.extend(UnloadModelRoute, { modelType(backend, secret) { let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); - let type = backendModel.get('type'); + let type = backendModel.get('engineType'); let types = { transit: 'transit-key', ssh: 'role-ssh', diff --git a/ui/app/routes/vault/cluster/settings/auth/configure.js b/ui/app/routes/vault/cluster/settings/auth/configure.js index c644c98e3..33e1e5b4b 100644 --- a/ui/app/routes/vault/cluster/settings/auth/configure.js +++ b/ui/app/routes/vault/cluster/settings/auth/configure.js @@ -10,7 +10,7 @@ export default Ember.Route.extend({ const { method } = this.paramsFor(this.routeName); return this.store.findAll('auth-method').then(() => { const model = this.store.peekRecord('auth-method', method); - const modelType = model && model.get('type'); + const modelType = model && model.get('methodType'); if (!model || (modelType !== 'token' && !METHODS.findBy('type', modelType))) { const error = new DS.AdapterError(); Ember.set(error, 'httpStatus', 404); diff --git a/ui/app/serializers/namespace.js b/ui/app/serializers/namespace.js new file mode 100644 index 000000000..01ab5c1cc --- /dev/null +++ b/ui/app/serializers/namespace.js @@ -0,0 +1,24 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalizeList(payload) { + const data = payload.data.keys + ? payload.data.keys.map(key => ({ + path: key, + // remove the trailing slash from the id + id: key.replace(/\/$/, ''), + })) + : payload.data; + + return data; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['deleteRecord', 'createRecord']; + let cid = (id || payload.id || '').replace(/\/$/, ''); + let normalizedPayload = nullResponses.includes(requestType) + ? { id: cid, path: cid } + : this.normalizeList(payload); + return this._super(store, primaryModelClass, normalizedPayload, id, requestType); + }, +}); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index bbf8ef90d..633283b96 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -3,7 +3,7 @@ import getStorage from '../lib/token-storage'; import ENV from 'vault/config/environment'; import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; -const { get, isArray, computed, getOwner } = Ember; +const { get, isArray, computed, getOwner, Service, inject } = Ember; const TOKEN_SEPARATOR = '☃'; const TOKEN_PREFIX = 'vault-'; @@ -13,7 +13,8 @@ const BACKENDS = supportedAuthBackends(); export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; -export default Ember.Service.extend({ +export default Service.extend({ + namespace: inject.service(), expirationCalcTS: null, init() { this._super(...arguments); @@ -69,17 +70,25 @@ export default Ember.Service.extend({ 'X-Vault-Token': this.get('currentToken'), }, }; + + let namespace = + typeof options.namespace === 'undefined' ? this.get('namespaceService.path') : options.namespace; + if (namespace) { + defaults.headers['X-Vault-Namespace'] = namespace; + } return Ember.$.ajax(Ember.assign(defaults, options)); }, renewCurrentToken() { + let namespace = this.get('authData.userRootNamespace'); const url = '/v1/auth/token/renew-self'; - return this.ajax(url, 'POST'); + return this.ajax(url, 'POST', { namespace }); }, revokeCurrentToken() { + let namespace = this.get('authData.userRootNamespace'); const url = '/v1/auth/token/revoke-self'; - return this.ajax(url, 'POST'); + return this.ajax(url, 'POST', { namespace }); }, calculateExpiration(resp, creationTime) { @@ -97,8 +106,9 @@ export default Ember.Service.extend({ }, persistAuthData() { - const [firstArg, resp] = arguments; + let [firstArg, resp] = arguments; let tokens = this.get('tokens'); + let currentNamespace = this.get('namespace.path') || ''; let tokenName; let options; let backend; @@ -110,7 +120,7 @@ export default Ember.Service.extend({ backend = options.backend; } - const currentBackend = BACKENDS.findBy('type', backend); + let currentBackend = BACKENDS.findBy('type', backend); let displayName; if (isArray(currentBackend.displayNamePath)) { displayName = currentBackend.displayNamePath.map(name => get(resp, name)).join('/'); @@ -118,8 +128,26 @@ export default Ember.Service.extend({ displayName = get(resp, currentBackend.displayNamePath); } - const { entity_id, policies, renewable } = resp; + let { entity_id, policies, renewable, namespace_path } = resp; + // here we prefer namespace_path if its defined, + // else we look and see if there's already a namespace saved + // and then finally we'll use the current query param if the others + // haven't set a value yet + // all of the typeof checks are necessary because the root namespace is '' + let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, ''); + // if we're logging in with token and there's no namespace_path, we can assume + // that the token belongs to the root namespace + if (backend === 'token' && !userRootNamespace) { + userRootNamespace = ''; + } + if (typeof userRootNamespace === 'undefined') { + userRootNamespace = this.get('authData.userRootNamespace'); + } + if (typeof userRootNamespace === 'undefined') { + userRootNamespace = currentNamespace; + } let data = { + userRootNamespace, displayName, backend: currentBackend, token: resp.client_token || get(resp, currentBackend.tokenPath), @@ -148,6 +176,7 @@ export default Ember.Service.extend({ this.set('allowExpiration', false); this.setTokenData(tokenName, data); return Ember.RSVP.resolve({ + namespace: currentNamespace || data.userRootNamespace, token: tokenName, isRoot: policies.includes('root'), }); @@ -253,7 +282,7 @@ export default Ember.Service.extend({ const adapter = this.clusterAdapter(); return adapter.authenticate(options).then(resp => { - return this.persistAuthData(options, resp.auth || resp.data); + return this.persistAuthData(options, resp.auth || resp.data, this.get('namespace.path')); }); }, @@ -269,6 +298,7 @@ export default Ember.Service.extend({ this.set('tokens', tokenNames); }, + // returns the key for the token to use currentTokenName: computed('activeCluster', 'tokens', 'tokens.[]', function() { const regex = new RegExp(this.get('activeCluster')); return this.get('tokens').find(key => regex.test(key)); diff --git a/ui/app/services/namespace.js b/ui/app/services/namespace.js new file mode 100644 index 000000000..256fe49c8 --- /dev/null +++ b/ui/app/services/namespace.js @@ -0,0 +1,40 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +const { Service, computed, inject } = Ember; +const ROOT_NAMESPACE = ''; +export default Service.extend({ + store: inject.service(), + auth: inject.service(), + userRootNamespace: computed.alias('auth.authData.userRootNamespace'), + //populated by the query param on the cluster route + path: null, + // list of namespaces available to the current user under the + // current namespace + accessibleNamespaces: null, + + inRootNamespace: computed.equal('path', ROOT_NAMESPACE), + + setNamespace(path) { + this.set('path', path); + }, + + findNamespacesForUser: task(function*() { + // uses the adapter and the raw response here since + // models get wiped when switching namespaces and we + // want to keep track of these separately + let store = this.get('store'); + let adapter = store.adapterFor('namespace'); + try { + let ns = yield adapter.findAll(store, 'namespace', null, { + adapterOptions: { + forUser: true, + namespace: this.get('userRootNamespace'), + }, + }); + this.set('accessibleNamespaces', ns.data.keys.map(n => n.replace(/\/$/, ''))); + } catch (e) { + //do nothing here + } + }).drop(), +}); diff --git a/ui/app/services/version.js b/ui/app/services/version.js index 5b3a079f7..487326da0 100644 --- a/ui/app/services/version.js +++ b/ui/app/services/version.js @@ -23,6 +23,7 @@ export default Service.extend({ hasDRReplication: hasFeature('DR Replication'), hasSentinel: hasFeature('Sentinel'), + hasNamespaces: hasFeature('Namespaces'), isEnterprise: computed.match('version', /\+.+$/), diff --git a/ui/app/styles/components/auth-form.scss b/ui/app/styles/components/auth-form.scss index 27e0229b8..0eb467766 100644 --- a/ui/app/styles/components/auth-form.scss +++ b/ui/app/styles/components/auth-form.scss @@ -2,4 +2,18 @@ @extend .box; @extend .is-bottomless; padding: 0; + position: relative; +} + +.auth-form .vault-loader { + position: absolute; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + background: rgba(255, 255, 255, 0.8); + z-index: 10; + display: flex; + justify-content: center; + align-items: center; } diff --git a/ui/app/styles/components/namespace-picker.scss b/ui/app/styles/components/namespace-picker.scss new file mode 100644 index 000000000..2dc07e2cf --- /dev/null +++ b/ui/app/styles/components/namespace-picker.scss @@ -0,0 +1,102 @@ +.namespace-picker { + border-right: 1px solid rgba($black, 0.5); + margin-right: $size-10; + position: relative; + padding: 0.5rem; + color: $white; + fill: $white; +} +.namespace-picker.no-namespaces { + border: none; + padding-right: 0; +} +.namespace-picker-trigger { + display: flex; + align-items: center; +} +.namespace-name { + display: inline-block; + margin-left: $size-10; + font-size: 1rem; +} +.namespace-picker-content { + width: 300px; + max-height: 300px; + overflow: auto; + border-radius: $radius; + box-shadow: $box-shadow, $box-shadow-high; +} +.namespace-picker-content .level-left { + max-width: 210px; + overflow-wrap: break-word; + word-wrap: break-word; + -ms-word-break: break-all; + word-break: break-all; + word-break: break-word; +} + +.namespace-header-bar { + padding: $size-11 $size-10; + box-shadow: $box-shadow; + font-weight: $font-weight-semibold; + min-height: 32px; + .namespace-manage-link { + text-decoration: none; + } +} + +.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); +} + +.namespace-list { + position: relative; + overflow: hidden; +} + +.namespace-link { + color: $black; + text-decoration: none; + font-weight: $font-weight-semibold; + padding: $size-10 $size-9; +} + +.leaf-panel { + transition: transform ease-in-out 250ms; + will-change: transform; + transform: translateX(0); + background: $white; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; +} +.leaf-panel-left { + transform: translateX(-300px); +} +.leaf-panel-adding, +.leaf-panel-current { + position: relative; + & .namespace-link:last-child { + margin-bottom: 4px; + } +} +.animated-list { + .leaf-panel-exiting, + .leaf-panel-adding { + transform: translateX(300px); + z-index: 20; + } +} +.leaf-panel-adding { + z-index: 100; +} diff --git a/ui/app/styles/components/namespace-reminder.scss b/ui/app/styles/components/namespace-reminder.scss new file mode 100644 index 000000000..60c041ea6 --- /dev/null +++ b/ui/app/styles/components/namespace-reminder.scss @@ -0,0 +1,12 @@ +.namespace-reminder { + color: $grey; + margin: 0 0 $size-6 0; +} + +.console-reminder p.namespace-reminder { + margin-bottom: 0; + opacity: 0.7; + position: absolute; + color: $grey; + font-family: $family-monospace; +} diff --git a/ui/app/styles/components/splash-page.scss b/ui/app/styles/components/splash-page.scss index a361a6a41..3f1415c55 100644 --- a/ui/app/styles/components/splash-page.scss +++ b/ui/app/styles/components/splash-page.scss @@ -3,13 +3,19 @@ a.splash-page-logo { svg { transform: scale(.5); transform-origin: left; - fill: currentColor; + fill: $white; } } +a.splash-page-logo.is-active { + background: transparent; +} .splash-page-container { margin: $size-2 0; } .splash-page-header { - padding: .75rem 1.5rem; + padding: $size-6 $size-5; +} +.splash-page-sub-header { + margin: 0 $size-5 $size-6; } diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 9a4c21727..0b8b08739 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -62,6 +62,8 @@ @import "./components/login-form"; @import "./components/masked-input"; @import "./components/message-in-page"; +@import "./components/namespace-picker"; +@import "./components/namespace-reminder"; @import "./components/page-header"; @import "./components/popup-menu"; @import "./components/radial-progress"; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index a5d68f93a..69be8d52d 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -199,6 +199,17 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); } } +.button.icon { + box-sizing: border-box; + padding: 0 $size-11; + height: 24px; + width: 24px; + &, + & .icon { + min-width: 0; + } +} + .button .icon.auto-width { width: auto; margin: 0 !important; diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index fbd777227..eb09d3ee4 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -43,6 +43,9 @@ a.navbar-item { left: 3.5em; top: 0; height: 3.25rem; + &.with-ns-picker { + left: 0; + } } .icon.edition-icon { diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs index b7b1a0bbd..3ec62daa1 100644 --- a/ui/app/templates/application.hbs +++ b/ui/app/templates/application.hbs @@ -1,120 +1,5 @@
- {{#if showNav}} - - - - {{partial 'svg/vault-logo'}} - - - - - - - - - - {{console/ui-panel isFullscreen=consoleFullscreen}} - - - {{/if}} -
- {{#each flashMessages.queue as |flash|}} - {{#flash-message data-test-flash-message=true flash=flash as |component flash close|}} - {{#if flash.componentName}} - {{component flash.componentName content=flash.content}} - {{else}} -
- {{get (message-types flash.type) "text"}} -
- - {{flash.message}} - - - {{/if}} - {{/flash-message}} - {{/each}} -
- {{#if showNav}} -
-
- {{component - (if - (or - (is-after (now interval=1000) auth.tokenExpirationDate) - (and activeClusterName auth.currentToken) - ) - 'token-expire-warning' - null - ) - }} - {{#unless (and - activeClusterName - auth.currentToken - (is-after (now interval=1000) auth.tokenExpirationDate) - ) - }} - {{outlet}} - {{/unless}} -
-
- {{else}} - {{outlet}} - {{/if}} - + {{outlet}}