diff --git a/ui-v2/app/components/app-view/index.hbs b/ui-v2/app/components/app-view/index.hbs index 575a85ad3a..ababdd8b49 100644 --- a/ui-v2/app/components/app-view/index.hbs +++ b/ui-v2/app/components/app-view/index.hbs @@ -3,14 +3,45 @@
{{#each flashMessages.queue as |flash|}} - {{! flashes automatically ucfirst the type }} + {{#let (lowercase component.flashType) (lowercase flash.action) as |status type|}} + {{! flashes automatically ucfirst the type }} -

- - {{component.flashType}}! - - {{yield}} -

+

+ + {{capitalize status}}! + + {{#yield-slot name="notification" params=(block-params status type flash.item)}} + {{yield}} + {{#if (eq type 'logout')}} + {{#if (eq status 'success') }} + You are now logged out. + {{else}} + There was an error logging out. + {{/if}} + {{else if (eq type 'authorize')}} + {{#if (eq status 'success') }} + You are now logged in. + {{else}} + There was an error, please check your SecretID/Token + {{/if}} + {{/if}} + {{else}} + {{#if (eq type 'logout')}} + {{#if (eq status 'success') }} + You are now logged out. + {{else}} + There was an error logging out. + {{/if}} + {{else if (eq type 'authorize')}} + {{#if (eq status 'success') }} + You are now logged in. + {{else}} + There was an error, please check your SecretID/Token + {{/if}} + {{/if}} + {{/yield-slot}} +

+ {{/let}}
{{/each}}
diff --git a/ui-v2/app/components/data-sink/README.mdx b/ui-v2/app/components/data-sink/README.mdx new file mode 100644 index 0000000000..9eaa0ff4ab --- /dev/null +++ b/ui-v2/app/components/data-sink/README.mdx @@ -0,0 +1,62 @@ +## DataSink + +```handlebars + +``` + +### Arguments + +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `sink` | `String` | | The location of the sink, this should map to a string based URI | +| `data` | `Object` | | The data to be saved to the current instance, null or an empty string means remove | +| `onchange` | `Function` | | The action to fire when the data has arrived to the sink. Emits an Event-like object with a `data` property containing the data, if the data was deleted this is `undefined`. | +| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. | + +### Methods/Actions/api + +| Method/Action | Description | +| --- | --- | +| `open` | Manually add or remove fom the data sink | + +The component takes a `sink` or an identifier (a uri) for the location of a sink and then emits `onchange` events whenever that data has been arrived to the sink (whether persisted or removed). If an error occurs whilst listening for data changes, an `onerror` event is emitted. + +Behind the scenes in the Consul UI we map URIs back to our `ember-data` backed `Repositories` meaning we can essentially redesign the URIs used for our data to more closely fit our needs. For example we currently require that **all** HTTP API URIs begin with `/dc/nspace/` values whether they require them or not. + +`DataSink` is not just restricted to HTTP API data, and can be configured to listen for data changes using a variety of methods and sources. For example we have also configured `DataSink` to send data to `LocalStorage` using the `settings://` pseudo-protocol in the URI (See examples below). + + +### Examples + +```handlebars + + + + + {{item.Name}} +``` + +```handlebars + + {{item.Name}} +``` + +### See + +- [Component Source Code](./index.js) +- [Template Source Code](./index.hbs) + +--- diff --git a/ui-v2/app/components/data-sink/index.hbs b/ui-v2/app/components/data-sink/index.hbs new file mode 100644 index 0000000000..a809747a7c --- /dev/null +++ b/ui-v2/app/components/data-sink/index.hbs @@ -0,0 +1,4 @@ +{{yield (hash + open=(action 'open') + state=state +)}} diff --git a/ui-v2/app/components/data-sink/index.js b/ui-v2/app/components/data-sink/index.js new file mode 100644 index 0000000000..c863e5dc16 --- /dev/null +++ b/ui-v2/app/components/data-sink/index.js @@ -0,0 +1,105 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { set, get, computed } from '@ember/object'; + +import { once } from 'consul-ui/utils/dom/event-source'; + +export default Component.extend({ + tagName: '', + + service: service('data-sink/service'), + dom: service('dom'), + logger: service('logger'), + + onchange: function(e) {}, + onerror: function(e) {}, + + state: computed('instance', 'instance.{dirtyType,isSaving}', function() { + let id; + const isSaving = get(this, 'instance.isSaving'); + const dirtyType = get(this, 'instance.dirtyType'); + if (typeof isSaving === 'undefined' && typeof dirtyType === 'undefined') { + id = 'idle'; + } else { + switch (dirtyType) { + case 'created': + id = isSaving ? 'creating' : 'create'; + break; + case 'updated': + id = isSaving ? 'updating' : 'update'; + break; + case 'deleted': + case undefined: + id = isSaving ? 'removing' : 'remove'; + break; + } + id = `active.${id}`; + } + return { + matches: name => id.indexOf(name) !== -1, + }; + }), + + init: function() { + this._super(...arguments); + this._listeners = this.dom.listeners(); + }, + willDestroy: function() { + this._super(...arguments); + this._listeners.remove(); + }, + source: function(cb) { + const source = once(cb); + const error = err => { + set(this, 'instance', undefined); + try { + this.onerror(err); + this.logger.execute(err); + } catch (err) { + this.logger.execute(err); + } + }; + this._listeners.add(source, { + message: e => { + try { + set(this, 'instance', undefined); + this.onchange(e); + } catch (err) { + error(err); + } + }, + error: e => error(e), + }); + return source; + }, + didInsertElement: function() { + this._super(...arguments); + if (typeof this.data !== 'undefined') { + this.actions.open.apply(this, [this.data]); + } + }, + persist: function(data, instance) { + set(this, 'instance', this.service.prepare(this.sink, data, instance)); + this.source(() => this.service.persist(this.sink, this.instance)); + }, + remove: function(instance) { + set(this, 'instance', this.service.prepare(this.sink, null, instance)); + this.source(() => this.service.remove(this.sink, this.instance)); + }, + actions: { + open: function(data, instance) { + if (instance instanceof Event) { + instance = undefined; + } + if (typeof data === 'undefined') { + throw new Error('You must specify data to save, or null to remove'); + } + // potentially allow {} and "" as 'remove' flags + if (data === null || data === '') { + this.remove(instance); + } else { + this.persist(data, instance); + } + }, + }, +}); diff --git a/ui-v2/app/components/empty-state/index.hbs b/ui-v2/app/components/empty-state/index.hbs new file mode 100644 index 0000000000..d42fa7ccc9 --- /dev/null +++ b/ui-v2/app/components/empty-state/index.hbs @@ -0,0 +1,21 @@ +{{yield}} +
+
+ {{#yield-slot name="header"}} + {{yield}} + {{/yield-slot}} + {{#yield-slot name="subheader"}} + {{yield}} + {{/yield-slot}} +
+

+ {{#yield-slot name="body"}} + {{yield}} + {{/yield-slot}} +

+ {{#yield-slot name="actions"}} +
    + {{yield}} +
+ {{/yield-slot}} +
\ No newline at end of file diff --git a/ui-v2/app/components/empty-state/index.js b/ui-v2/app/components/empty-state/index.js new file mode 100644 index 0000000000..a7be4db131 --- /dev/null +++ b/ui-v2/app/components/empty-state/index.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; +import Slotted from 'block-slots'; + +export default Component.extend(Slotted, { + tagName: '', +}); diff --git a/ui-v2/app/components/hashicorp-consul/index.hbs b/ui-v2/app/components/hashicorp-consul/index.hbs index 6b1fa800d6..7f495c8fa2 100644 --- a/ui-v2/app/components/hashicorp-consul/index.hbs +++ b/ui-v2/app/components/hashicorp-consul/index.hbs @@ -111,6 +111,44 @@
  • Settings
  • +{{#if (env 'CONSUL_ACLS_ENABLED')}} + + + {{#if (not-eq token.AccessorID undefined)}} +
  • + + + Logout + + + {{#if token.AccessorID}} +
  • +
    +
    + My ACL Token
    + AccessorID +
    +
    + {{substr token.AccessorID -8}} +
    +
    +
  • +
  • + {{/if}} +
  • + +
  • + + + + {{/if}} +
    +{{/if}}
    diff --git a/ui-v2/app/components/hashicorp-consul/index.js b/ui-v2/app/components/hashicorp-consul/index.js index f705f1db4c..82eca600e9 100644 --- a/ui-v2/app/components/hashicorp-consul/index.js +++ b/ui-v2/app/components/hashicorp-consul/index.js @@ -1,9 +1,18 @@ import Component from '@ember/component'; import { inject as service } from '@ember/service'; -import { computed } from '@ember/object'; +import { get, set, computed } from '@ember/object'; +import { getOwner } from '@ember/application'; export default Component.extend({ dom: service('dom'), + env: service('env'), + feedback: service('feedback'), + router: service('router'), + http: service('repository/type/event-source'), + client: service('client/http'), + store: service('store'), + settings: service('settings'), + didInsertElement: function() { this.dom.root().classList.remove('template-with-vertical-menu'); }, @@ -16,7 +25,103 @@ export default Component.extend({ }) !== 'undefined' ); }), + forwardForACL: function(token) { + let routeName = this.router.currentRouteName; + const route = getOwner(this).lookup(`route:${routeName}`); + // a null AccessorID means we are in legacy mode + // take the user to the legacy acls + // otherwise just refresh the page + if (get(token, 'AccessorID') === null) { + // returning false for a feedback action means even though + // its successful, please skip this notification and don't display it + return route.transitionTo('dc.acls'); + } else { + // TODO: Ideally we wouldn't need to use env() at a component level + // transitionTo should probably remove it instead if NSPACES aren't enabled + if (this.env.var('CONSUL_NSPACES_ENABLED') && get(token, 'Namespace') !== this.nspace) { + if (!routeName.startsWith('nspace')) { + routeName = `nspace.${routeName}`; + } + return route.transitionTo(`${routeName}`, `~${get(token, 'Namespace')}`, this.dc.Name); + } else { + if (route.routeName === 'dc.acls.index') { + return route.transitionTo('dc.acls.tokens.index'); + } + return route.refresh(); + } + } + }, actions: { + send: function(el, method, ...rest) { + const component = this.dom.component(el); + component.actions[method].apply(component, rest || []); + }, + changeToken: function(token = {}) { + const prev = this.token; + if (token === '') { + token = {}; + } + set(this, 'token', token); + // if this is just the initial 'find out what the current token is' + // then don't do anything + if (typeof prev === 'undefined') { + return; + } + let notification; + let action = () => this.forwardForACL(token); + switch (true) { + case get(this, 'token.AccessorID') === null && get(this, 'token.SecretID') === null: + // 'everything is null, 403 this needs deleting' token + this.settings.delete('token'); + return; + case get(prev, 'AccessorID') === null && get(prev, 'SecretID') === null: + // we just had an 'everything is null, this needs deleting' token + // reject and break so this acts differently to just logging out + action = () => Promise.reject({}); + notification = 'authorize'; + break; + case typeof get(prev, 'AccessorID') !== 'undefined' && + typeof get(this, 'token.AccessorID') !== 'undefined': + // change of both Accessor and Secret, means use + notification = 'use'; + break; + case get(this, 'token.AccessorID') === null && + typeof get(this, 'token.SecretID') !== 'undefined': + // legacy login, don't do anything as we don't use self for auth here but the endpoint itself + // self is successful, but skip this notification and don't display it + return this.forwardForACL(token); + case typeof get(prev, 'AccessorID') === 'undefined' && + typeof get(this, 'token.AccessorID') !== 'undefined': + // normal login + notification = 'authorize'; + break; + case (typeof get(prev, 'AccessorID') !== 'undefined' || get(prev, 'AccessorID') === null) && + typeof get(this, 'token.AccessorID') === 'undefined': + //normal logout + notification = 'logout'; + break; + } + this.actions.reauthorize.apply(this, [ + { + type: notification, + action: action, + }, + ]); + }, + reauthorize: function(e) { + this.client.abort(); + this.http.resetCache(); + this.store.init(); + const type = get(e, 'type'); + this.feedback.execute( + e.action, + type, + function(type, e) { + return type; + }, + {} + ); + }, change: function(e) { const win = this.dom.viewport(); const $root = this.dom.root(); diff --git a/ui-v2/app/mixins/acl/with-actions.js b/ui-v2/app/mixins/acl/with-actions.js index 13fac3e81c..5b969af3b7 100644 --- a/ui-v2/app/mixins/acl/with-actions.js +++ b/ui-v2/app/mixins/acl/with-actions.js @@ -7,34 +7,16 @@ export default Mixin.create(WithBlockingActions, { settings: service('settings'), actions: { use: function(item) { - return this.feedback.execute(() => { - // old style legacy ACLs don't have AccessorIDs or Namespaces - // therefore set AccessorID to null, this way the frontend knows - // to use legacy ACLs - // set the Namespace to just use default - return this.settings - .persist({ - token: { - Namespace: 'default', - AccessorID: null, - SecretID: get(item, 'ID'), - }, - }) - .then(() => { - return this.transitionTo('dc.services'); - }); - }, 'use'); + return this.settings.persist({ + token: { + Namespace: 'default', + AccessorID: null, + SecretID: get(item, 'ID'), + }, + }); }, - // TODO: This is also used in tokens, probably an opportunity to dry this out logout: function(item) { - return this.feedback.execute(() => { - return this.settings.delete('token').then(() => { - // in this case we don't do the same as delete as we want to go to the new - // dc.acls.tokens page. If we get there via the dc.acls redirect/rewrite - // then we lose the flash message - return this.transitionTo('dc.acls.tokens'); - }); - }, 'logout'); + return this.settings.delete('token'); }, clone: function(item) { return this.feedback.execute(() => { diff --git a/ui-v2/app/mixins/token/with-actions.js b/ui-v2/app/mixins/token/with-actions.js index 72b207b1f0..6c9509e427 100644 --- a/ui-v2/app/mixins/token/with-actions.js +++ b/ui-v2/app/mixins/token/with-actions.js @@ -7,40 +7,24 @@ export default Mixin.create(WithBlockingActions, { settings: service('settings'), actions: { use: function(item) { - return this.feedback.execute(() => { - return this.repo - .findBySlug( - get(item, 'AccessorID'), - this.modelFor('dc').dc.Name, - this.modelFor('nspace').nspace.substr(1) - ) - .then(item => { - return this.settings - .persist({ - token: { - AccessorID: get(item, 'AccessorID'), - SecretID: get(item, 'SecretID'), - Namespace: get(item, 'Namespace'), - }, - }) - .then(() => { - // using is similar to delete in that - // if you use from the listing page, stay on the listing page - // whereas if you use from the detail page, take me back to the listing page - return this.afterDelete(...arguments); - }); + return this.repo + .findBySlug( + get(item, 'AccessorID'), + this.modelFor('dc').dc.Name, + this.modelFor('nspace').nspace.substr(1) + ) + .then(item => { + return this.settings.persist({ + token: { + AccessorID: get(item, 'AccessorID'), + SecretID: get(item, 'SecretID'), + Namespace: get(item, 'Namespace'), + }, }); - }, 'use'); + }); }, logout: function(item) { - return this.feedback.execute(() => { - return this.settings.delete('token').then(() => { - // logging out is similar to delete in that - // if you log out from the listing page, stay on the listing page - // whereas if you logout from the detail page, take me back to the listing page - return this.afterDelete(...arguments); - }); - }, 'logout'); + return this.settings.delete('token'); }, clone: function(item) { let cloned; diff --git a/ui-v2/app/routes/application.js b/ui-v2/app/routes/application.js index 6637878f6e..45b9bc5b74 100644 --- a/ui-v2/app/routes/application.js +++ b/ui-v2/app/routes/application.js @@ -63,12 +63,18 @@ export default Route.extend(WithBlockingActions, { // 403 page // To note: Consul only gives you back a 403 if a non-existent token has been sent in the header // if a token has not been sent at all, it just gives you a 200 with an empty dataset + // We set a completely null token here, which is different to just deleting a token + // in that deleting a token means 'logout' whereas setting it to completely null means + // there was a 403. This is only required to get around the legacy tokens + // a lot of this can go once we don't support legacy tokens if (error.status === '403') { - return this.feedback.execute(() => { - return this.settings.delete('token').then(() => { - return Promise.reject(this.transitionTo('dc.acls.tokens', model.dc.Name)); - }); - }, 'authorize'); + return this.settings.persist({ + token: { + AccessorID: null, + SecretID: null, + Namespace: null, + }, + }); } if (error.status === '') { error.message = 'Error'; diff --git a/ui-v2/app/routes/dc/acls.js b/ui-v2/app/routes/dc/acls.js index 0b1fd3655e..f7792dd950 100644 --- a/ui-v2/app/routes/dc/acls.js +++ b/ui-v2/app/routes/dc/acls.js @@ -1,6 +1,5 @@ import Route from '@ember/routing/route'; import { get } from '@ember/object'; -import { env } from 'consul-ui/env'; import { inject as service } from '@ember/service'; import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions'; export default Route.extend(WithBlockingActions, { @@ -11,42 +10,29 @@ export default Route.extend(WithBlockingActions, { actions: { authorize: function(secret, nspace) { const dc = this.modelFor('dc').dc.Name; - return this.feedback.execute(() => { - return this.repo.self(secret, dc).then(item => { - return this.settings - .persist({ - token: { - Namespace: get(item, 'Namespace'), - AccessorID: get(item, 'AccessorID'), - SecretID: secret, - }, - }) - .then(item => { - // a null AccessorID means we are in legacy mode - // take the user to the legacy acls - // otherwise just refresh the page - if (get(item, 'token.AccessorID') === null) { - // returning false for a feedback action means even though - // its successful, please skip this notification and don't display it - return this.transitionTo('dc.acls').then(function() { - return false; - }); - } else { - // TODO: Ideally we wouldn't need to use env() at a route level - // transitionTo should probably remove it instead if NSPACES aren't enabled - if (env('CONSUL_NSPACES_ENABLED') && get(item, 'token.Namespace') !== nspace) { - let routeName = this.router.currentRouteName; - if (!routeName.startsWith('nspace')) { - routeName = `nspace.${routeName}`; - } - return this.transitionTo(`${routeName}`, `~${get(item, 'token.Namespace')}`, dc); - } else { - this.refresh(); - } - } - }); + return this.repo + .self(secret, dc) + .then(item => { + return this.settings.persist({ + token: { + Namespace: get(item, 'Namespace'), + AccessorID: get(item, 'AccessorID'), + SecretID: secret, + }, + }); + }) + .catch(e => { + this.feedback.execute( + () => { + return Promise.resolve(); + }, + 'authorize', + function(type, e) { + return 'error'; + }, + {} + ); }); - }, 'authorize'); }, }, }); diff --git a/ui-v2/app/services/data-sink/protocols/http.js b/ui-v2/app/services/data-sink/protocols/http.js new file mode 100644 index 0000000000..82999c9834 --- /dev/null +++ b/ui-v2/app/services/data-sink/protocols/http.js @@ -0,0 +1,33 @@ +import Service, { inject as service } from '@ember/service'; +import { setProperties } from '@ember/object'; + +export default Service.extend({ + settings: service('settings'), + intention: service('repository/intention'), + prepare: function(sink, data, instance) { + const [, dc, nspace, model, slug] = sink.split('/'); + const repo = this[model]; + if (slug === '') { + instance = repo.create({ + Datacenter: dc, + Namespace: nspace, + }); + } else { + if (typeof instance === 'undefined') { + instance = repo.peek(slug); + } + } + setProperties(instance, data); + return instance; + }, + persist: function(sink, instance) { + const [, , , /*dc*/ /*nspace*/ model] = sink.split('/'); + const repo = this[model]; + return repo.persist(instance); + }, + remove: function(sink, instance) { + const [, , , /*dc*/ /*nspace*/ model] = sink.split('/'); + const repo = this[model]; + return repo.remove(instance); + }, +}); diff --git a/ui-v2/app/services/data-sink/protocols/local-storage.js b/ui-v2/app/services/data-sink/protocols/local-storage.js new file mode 100644 index 0000000000..6483b8c33e --- /dev/null +++ b/ui-v2/app/services/data-sink/protocols/local-storage.js @@ -0,0 +1,25 @@ +import Service, { inject as service } from '@ember/service'; +import { setProperties } from '@ember/object'; + +export default Service.extend({ + settings: service('settings'), + prepare: function(sink, data, instance) { + if (data === null || data || '') { + return instance; + } + setProperties(instance, data); + return instance; + }, + persist: function(sink, instance) { + const slug = sink.split(':').pop(); + const repo = this.settings; + return repo.persist({ + [slug]: instance, + }); + }, + remove: function(sink, instance) { + const slug = sink.split(':').pop(); + const repo = this.settings; + return repo.delete(slug); + }, +}); diff --git a/ui-v2/app/services/data-sink/service.js b/ui-v2/app/services/data-sink/service.js new file mode 100644 index 0000000000..48db37451f --- /dev/null +++ b/ui-v2/app/services/data-sink/service.js @@ -0,0 +1,34 @@ +import Service, { inject as service } from '@ember/service'; + +const parts = function(uri) { + if (uri.indexOf('://') === -1) { + uri = `data://${uri}`; + } + const url = new URL(uri); + let pathname = url.pathname; + if (pathname.startsWith('//')) { + pathname = pathname.substr(2); + } + const providerName = url.protocol.substr(0, url.protocol.length - 1); + return [providerName, pathname]; +}; +export default Service.extend({ + data: service('data-sink/protocols/http'), + settings: service('data-sink/protocols/local-storage'), + + prepare: function(uri, data, assign) { + const [providerName, pathname] = parts(uri); + const provider = this[providerName]; + return provider.prepare(pathname, data, assign); + }, + persist: function(uri, data) { + const [providerName, pathname] = parts(uri); + const provider = this[providerName]; + return provider.persist(pathname, data); + }, + remove: function(uri, data) { + const [providerName, pathname] = parts(uri); + const provider = this[providerName]; + return provider.remove(pathname, data); + }, +}); diff --git a/ui-v2/app/services/env.js b/ui-v2/app/services/env.js index 2449c5dfe5..5442527236 100644 --- a/ui-v2/app/services/env.js +++ b/ui-v2/app/services/env.js @@ -2,7 +2,12 @@ import Service from '@ember/service'; import { env } from 'consul-ui/env'; export default Service.extend({ + // deprecated + // TODO: Remove this elsewhere in the app and use var instead env: function(key) { return env(key); }, + var: function(key) { + return env(key); + }, }); diff --git a/ui-v2/app/services/repository/type/event-source.js b/ui-v2/app/services/repository/type/event-source.js index 0e6c4a9f5a..95dafe1eb3 100644 --- a/ui-v2/app/services/repository/type/event-source.js +++ b/ui-v2/app/services/repository/type/event-source.js @@ -83,6 +83,7 @@ const createProxy = function(repo, find, settings, cache, serialize = JSON.strin }; }; let cache = null; +let cacheMap = null; export default LazyProxyService.extend({ store: service('store'), settings: service('settings'), @@ -91,10 +92,18 @@ export default LazyProxyService.extend({ init: function() { this._super(...arguments); if (cache === null) { - cache = createCache({}); + this.resetCache(); } }, + resetCache: function() { + Object.entries(cacheMap || {}).forEach(function([key, item]) { + item.close(); + }); + cacheMap = {}; + cache = createCache(cacheMap); + }, willDestroy: function() { + cacheMap = null; cache = null; }, shouldProxy: function(content, method) { diff --git a/ui-v2/app/styles/base/components/menu-panel/layout.scss b/ui-v2/app/styles/base/components/menu-panel/layout.scss index c40289c4e0..a1d6385279 100644 --- a/ui-v2/app/styles/base/components/menu-panel/layout.scss +++ b/ui-v2/app/styles/base/components/menu-panel/layout.scss @@ -23,6 +23,9 @@ top: 0; left: calc(100% + 10px); } +%menu-panel dl { + padding: 0.9em 1em; +} %menu-panel > ul > li > div[role='menu'] { @extend %menu-panel-sub-panel; } diff --git a/ui-v2/app/styles/base/components/menu-panel/skin.scss b/ui-v2/app/styles/base/components/menu-panel/skin.scss index 427a67994e..df6b0cbf02 100644 --- a/ui-v2/app/styles/base/components/menu-panel/skin.scss +++ b/ui-v2/app/styles/base/components/menu-panel/skin.scss @@ -7,6 +7,9 @@ border-color: $gray-300; background-color: $white; } +%menu-panel dd { + color: $gray-500; +} %menu-panel > ul > li { list-style-type: none; } diff --git a/ui-v2/app/styles/base/components/popover-menu/index.scss b/ui-v2/app/styles/base/components/popover-menu/index.scss index 20c95035a8..00d566ba45 100644 --- a/ui-v2/app/styles/base/components/popover-menu/index.scss +++ b/ui-v2/app/styles/base/components/popover-menu/index.scss @@ -4,3 +4,18 @@ @import '../confirmation-alert/index'; @import './skin'; @import './layout'; +%with-popover-menu > input { + @extend %popover-menu; +} +%popover-menu { + @extend %display-toggle-siblings; +} +%popover-menu + label + div { + @extend %popover-menu-panel; +} +%popover-menu + label > * { + @extend %toggle-button; +} +%popover-menu-panel { + @extend %menu-panel; +} diff --git a/ui-v2/app/styles/base/components/popover-menu/layout.scss b/ui-v2/app/styles/base/components/popover-menu/layout.scss index dec50bbe23..78916fb21a 100644 --- a/ui-v2/app/styles/base/components/popover-menu/layout.scss +++ b/ui-v2/app/styles/base/components/popover-menu/layout.scss @@ -1,3 +1,7 @@ +%with-popover-menu { + position: relative; +} + %more-popover-menu { @extend %display-toggle-siblings; } diff --git a/ui-v2/app/styles/components/app-view.scss b/ui-v2/app/styles/components/app-view.scss index fb5ff2ec61..16315f24aa 100644 --- a/ui-v2/app/styles/components/app-view.scss +++ b/ui-v2/app/styles/components/app-view.scss @@ -72,16 +72,12 @@ main { [role='tabpanel'] > p:only-child, .template-error > div, %app-view-content > p:only-child, -%app-view > div.disabled > div, %app-view.empty > div { @extend %app-view-content-empty; } [role='tabpanel'] > *:first-child { margin-top: 1.25em; } -%app-view > div.disabled > div { - margin-top: 0 !important; -} /* toggleable toolbar for short screens */ [for='toolbar-toggle'] { diff --git a/ui-v2/app/styles/components/app-view/skin.scss b/ui-v2/app/styles/components/app-view/skin.scss index e8ba60393a..7d10f4f655 100644 --- a/ui-v2/app/styles/components/app-view/skin.scss +++ b/ui-v2/app/styles/components/app-view/skin.scss @@ -47,7 +47,6 @@ } [role='tabpanel'] > p:only-child, .template-error > div, -%app-view-content > p:only-child, -%app-view > div.disabled > div { +%app-view-content > p:only-child { @extend %frame-gray-500; } diff --git a/ui-v2/app/styles/components/empty-state.scss b/ui-v2/app/styles/components/empty-state.scss new file mode 100644 index 0000000000..cb4bcad847 --- /dev/null +++ b/ui-v2/app/styles/components/empty-state.scss @@ -0,0 +1,4 @@ +@import './empty-state/index'; +.empty-state { + @extend %empty-state; +} diff --git a/ui-v2/app/styles/components/empty-state/index.scss b/ui-v2/app/styles/components/empty-state/index.scss new file mode 100644 index 0000000000..65729024e4 --- /dev/null +++ b/ui-v2/app/styles/components/empty-state/index.scss @@ -0,0 +1,15 @@ +@import './skin'; +@import './layout'; +%empty-state header :first-child { + @extend %empty-state-header; +} +%empty-state header :nth-child(2) { + @extend %empty-state-subheader; +} +%empty-state > ul > li > *, +%empty-state > ul > li > label > button { + @extend %empty-state-anchor; +} +%empty-state > ul > li { + @extend %with-popover-menu; +} diff --git a/ui-v2/app/styles/components/empty-state/layout.scss b/ui-v2/app/styles/components/empty-state/layout.scss new file mode 100644 index 0000000000..fbd84a9384 --- /dev/null +++ b/ui-v2/app/styles/components/empty-state/layout.scss @@ -0,0 +1,21 @@ +%empty-state-header { + padding: 0; + margin: 0; +} +%empty-state { + width: 320px; + margin: 0 auto; +} +%empty-state-header { + margin-bottom: -3px; +} +%empty-state header { + margin-bottom: 0.5em; +} +%empty-state > ul { + display: flex; + justify-content: space-between; + + margin-top: 1.5em; + padding-top: 0.5em; +} diff --git a/ui-v2/app/styles/components/empty-state/skin.scss b/ui-v2/app/styles/components/empty-state/skin.scss new file mode 100644 index 0000000000..9add676b57 --- /dev/null +++ b/ui-v2/app/styles/components/empty-state/skin.scss @@ -0,0 +1,31 @@ +%empty-state-header { + border-bottom: none; +} +%empty-state header { + color: $gray-500; +} +%empty-state header::before { + background-color: $gray-500; + font-size: 2.6em; + position: relative; + top: -3px; + float: left; + margin-right: 10px; +} +%empty-state-anchor { + @extend %anchor; +} +%empty-state-anchor::before { + margin-right: 0.5em; + background-color: $blue-500; + font-size: 0.9em; +} +%empty-state.unauthorized header::before { + @extend %with-alert-circle-outline-mask, %as-pseudo; +} +%empty-state .docs-link > *::before { + @extend %with-docs-mask, %as-pseudo; +} +%empty-state .learn-link > *::before { + @extend %with-learn-mask, %as-pseudo; +} diff --git a/ui-v2/app/styles/components/feedback-dialog.scss b/ui-v2/app/styles/components/feedback-dialog.scss index 61c93da807..29ac1c663b 100644 --- a/ui-v2/app/styles/components/feedback-dialog.scss +++ b/ui-v2/app/styles/components/feedback-dialog.scss @@ -1,5 +1,5 @@ @import './feedback-dialog/index'; -main .with-feedback { +.with-feedback { @extend %feedback-dialog-inline; } %feedback-dialog-inline .feedback-dialog-out { diff --git a/ui-v2/app/styles/components/index.scss b/ui-v2/app/styles/components/index.scss index 0b9add75d7..19d60deba3 100644 --- a/ui-v2/app/styles/components/index.scss +++ b/ui-v2/app/styles/components/index.scss @@ -26,6 +26,7 @@ @import './sort-control'; @import './discovery-chain'; @import './consul-intention-list'; +@import './empty-state'; @import './tabular-details'; @import './tabular-collection'; diff --git a/ui-v2/app/styles/components/main-nav-horizontal/skin.scss b/ui-v2/app/styles/components/main-nav-horizontal/skin.scss index a4f6e35df8..acdf5d158e 100644 --- a/ui-v2/app/styles/components/main-nav-horizontal/skin.scss +++ b/ui-v2/app/styles/components/main-nav-horizontal/skin.scss @@ -8,7 +8,7 @@ font-size: 1.2em; } -%main-nav-horizontal button { +%main-nav-horizontal label > button { font-size: inherit; font-weight: inherit; color: inherit; diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 6c2029d262..d0f3920aec 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -44,6 +44,8 @@ pre code, %phrase-editor input { @extend %p1; } +%menu-panel dl, +%empty-state-anchor, .type-dialog, %table td p, %table td, @@ -56,9 +58,9 @@ pre code, @extend %p2; } .template-error > div, +%empty-state-subheader, %button, %main-content p, -%app-view > div.disabled > div, %form-element-note, %menu-panel-separator, %form-element-error > strong { @@ -77,6 +79,7 @@ pre code, %button { font-weight: $typo-weight-semibold; } +%menu-panel dt, %route-card section dt, %route-card header:not(.short) dd, %splitter-card > header { @@ -88,6 +91,8 @@ pre code, /**/ /* resets */ +%menu-panel dt span, +%empty-state-subheader, %main-content label a[rel*='help'], %pill, %form-element > strong, diff --git a/ui-v2/app/styles/routes/dc/acls/tokens/index.scss b/ui-v2/app/styles/routes/dc/acls/tokens/index.scss index b4bcd1f8c8..62168df180 100644 --- a/ui-v2/app/styles/routes/dc/acls/tokens/index.scss +++ b/ui-v2/app/styles/routes/dc/acls/tokens/index.scss @@ -18,9 +18,9 @@ .template-token.template-list main .notice { margin-top: -20px; } -.template-token.template-edit dd { +.template-token.template-edit main dd { display: flex; } -.template-token.template-edit dl { +.template-token.template-edit main dl { @extend %form-row; } diff --git a/ui-v2/app/templates/dc/acls/-authorization.hbs b/ui-v2/app/templates/dc/acls/-authorization.hbs index 137266faa0..1f08a99fd0 100644 --- a/ui-v2/app/templates/dc/acls/-authorization.hbs +++ b/ui-v2/app/templates/dc/acls/-authorization.hbs @@ -5,9 +5,9 @@
    {{! authorize is in the routes/dc/acls.js route }} - + diff --git a/ui-v2/app/templates/dc/acls/-disabled.hbs b/ui-v2/app/templates/dc/acls/-disabled.hbs index 4a8d1ffb9a..bd54f7fc40 100644 --- a/ui-v2/app/templates/dc/acls/-disabled.hbs +++ b/ui-v2/app/templates/dc/acls/-disabled.hbs @@ -1,6 +1,18 @@ -

    - ACLs are disabled in this Consul cluster. This is the default behavior, as you have to explicitly enable them. -

    -

    - Learn more in the ACL documentation -

    + + +

    Welcome to ACLs

    +
    + +

    + ACLs are not enabled. We strongly encourage the use of ACLs in production environments for the best security practices. +

    +
    + + + + +
    diff --git a/ui-v2/app/templates/dc/acls/-notifications.hbs b/ui-v2/app/templates/dc/acls/-notifications.hbs index 9455b65f0e..1b577c6ef8 100644 --- a/ui-v2/app/templates/dc/acls/-notifications.hbs +++ b/ui-v2/app/templates/dc/acls/-notifications.hbs @@ -16,12 +16,6 @@ {{else}} There was an error deleting your ACL token. {{/if}} -{{ else if (eq type 'logout')}} - {{#if (eq status 'success') }} - You are now logged out. - {{else}} - There was an error logging out. - {{/if}} {{ else if (eq type 'use')}} {{#if (eq status 'success') }} Now using new ACL token. diff --git a/ui-v2/app/templates/dc/acls/policies/edit.hbs b/ui-v2/app/templates/dc/acls/policies/edit.hbs index ab08fe8827..cdc0ac6bc2 100644 --- a/ui-v2/app/templates/dc/acls/policies/edit.hbs +++ b/ui-v2/app/templates/dc/acls/policies/edit.hbs @@ -7,7 +7,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/policies/notifications'}} diff --git a/ui-v2/app/templates/dc/acls/policies/index.hbs b/ui-v2/app/templates/dc/acls/policies/index.hbs index 589ff3f8e3..0dd5e37f89 100644 --- a/ui-v2/app/templates/dc/acls/policies/index.hbs +++ b/ui-v2/app/templates/dc/acls/policies/index.hbs @@ -3,7 +3,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/policies/notifications'}} diff --git a/ui-v2/app/templates/dc/acls/roles/edit.hbs b/ui-v2/app/templates/dc/acls/roles/edit.hbs index 4c3e8563b9..964084d0d8 100644 --- a/ui-v2/app/templates/dc/acls/roles/edit.hbs +++ b/ui-v2/app/templates/dc/acls/roles/edit.hbs @@ -7,7 +7,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/roles/notifications'}} diff --git a/ui-v2/app/templates/dc/acls/roles/index.hbs b/ui-v2/app/templates/dc/acls/roles/index.hbs index 7db9f88b20..88700fdca5 100644 --- a/ui-v2/app/templates/dc/acls/roles/index.hbs +++ b/ui-v2/app/templates/dc/acls/roles/index.hbs @@ -3,7 +3,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/roles/notifications'}} diff --git a/ui-v2/app/templates/dc/acls/tokens/-notifications.hbs b/ui-v2/app/templates/dc/acls/tokens/-notifications.hbs index 5c98ecf4bf..c5770bf1e3 100644 --- a/ui-v2/app/templates/dc/acls/tokens/-notifications.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/-notifications.hbs @@ -16,24 +16,12 @@ {{else}} There was an error deleting the token. {{/if}} -{{ else if (eq type 'logout')}} - {{#if (eq status 'success') }} - You are now logged out. - {{else}} - There was an error logging out. - {{/if}} {{ else if (eq type 'clone')}} {{#if (eq status 'success') }} The token has been cloned as {{truncate subject.AccessorID 8 false}} {{else}} There was an error cloning the token. {{/if}} -{{ else if (eq type 'authorize')}} - {{#if (eq status 'success') }} - You are now logged in. - {{else}} - There was an error, please check your SecretID/Token - {{/if}} {{ else if (eq type 'use')}} {{#if (eq status 'success') }} You are now using the new ACL token diff --git a/ui-v2/app/templates/dc/acls/tokens/edit.hbs b/ui-v2/app/templates/dc/acls/tokens/edit.hbs index 853a662ce2..075575dbc6 100644 --- a/ui-v2/app/templates/dc/acls/tokens/edit.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/edit.hbs @@ -7,7 +7,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/tokens/notifications'}} diff --git a/ui-v2/app/templates/dc/acls/tokens/index.hbs b/ui-v2/app/templates/dc/acls/tokens/index.hbs index 0a0700312d..25c6a2c0e1 100644 --- a/ui-v2/app/templates/dc/acls/tokens/index.hbs +++ b/ui-v2/app/templates/dc/acls/tokens/index.hbs @@ -3,7 +3,12 @@ {{else}} {{title 'Access Controls'}} {{/if}} - + {{partial 'dc/acls/tokens/notifications'}} @@ -81,8 +86,8 @@ {{/if}} {{#if (eq item.AccessorID token.AccessorID) }} -
  • - +
  • +
    diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index 7e56cd6334..d4817d9291 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -1,8 +1,5 @@ {{title 'Services'}} - - {{partial 'dc/services/notifications'}} -

    Services {{format-number items.length}} total diff --git a/ui-v2/tests/acceptance/dc/acls/tokens/own-no-delete.feature b/ui-v2/tests/acceptance/dc/acls/tokens/own-no-delete.feature index f5e39ed73b..38375fb2e4 100644 --- a/ui-v2/tests/acceptance/dc/acls/tokens/own-no-delete.feature +++ b/ui-v2/tests/acceptance/dc/acls/tokens/own-no-delete.feature @@ -8,9 +8,12 @@ Feature: dc / acls / tokens / own-no-delete: The your current token has no delet SecretID: ee52203d-989f-4f7a-ab5a-2bef004164ca --- Scenario: On the listing page - Then I have settings like yaml + Given settings from yaml --- - consul:token: ~ + consul:token: + SecretID: secret + AccessorID: accessor + Namespace: default --- When I visit the tokens page for yaml --- diff --git a/ui-v2/tests/acceptance/dc/acls/tokens/use.feature b/ui-v2/tests/acceptance/dc/acls/tokens/use.feature index 9f7b1308f3..19c4aedbe7 100644 --- a/ui-v2/tests/acceptance/dc/acls/tokens/use.feature +++ b/ui-v2/tests/acceptance/dc/acls/tokens/use.feature @@ -7,15 +7,18 @@ Feature: dc / acls / tokens / use: Using an ACL token AccessorID: token SecretID: ee52203d-989f-4f7a-ab5a-2bef004164ca --- + And settings from yaml + --- + consul:token: + SecretID: secret + AccessorID: accessor + Namespace: default + --- Scenario: Using an ACL token from the listing page When I visit the tokens page for yaml --- dc: datacenter --- - Then I have settings like yaml - --- - consul:token: ~ - --- And I click actions on the tokens And I click use on the tokens And I click confirmUse on the tokens @@ -31,10 +34,6 @@ Feature: dc / acls / tokens / use: Using an ACL token dc: datacenter token: token --- - Then I have settings like yaml - --- - consul:token: ~ - --- And I click use And I click confirmUse Then "[data-notification]" has the "notification-use" class diff --git a/ui-v2/tests/steps.js b/ui-v2/tests/steps.js index b72cc6e826..43db01a773 100644 --- a/ui-v2/tests/steps.js +++ b/ui-v2/tests/steps.js @@ -51,12 +51,15 @@ const mb = function(path) { }; export default function(assert, library) { const pauseUntil = function(run, message = 'assertion timed out') { - return new Promise(function(r, reject) { + return new Promise(function(r) { let count = 0; let resolved = false; const retry = function() { return Promise.resolve(); }; + const reject = function() { + return Promise.reject(); + }; const resolve = function(str = message) { resolved = true; assert.ok(resolved, str); diff --git a/ui-v2/tests/unit/mixins/acl/with-actions-test.js b/ui-v2/tests/unit/mixins/acl/with-actions-test.js index 61c7aaada8..137e790835 100644 --- a/ui-v2/tests/unit/mixins/acl/with-actions-test.js +++ b/ui-v2/tests/unit/mixins/acl/with-actions-test.js @@ -22,36 +22,24 @@ module('Unit | Mixin | acl/with actions', function(hooks) { const subject = this.subject(); assert.ok(subject); }); - test('use persists the token and calls transitionTo correctly', function(assert) { - assert.expect(4); - this.owner.register( - 'service:feedback', - Service.extend({ - execute: function(cb, name) { - assert.equal(name, 'use'); - return cb(); - }, - }) - ); + test('use persists the token', function(assert) { + assert.expect(2); const item = { ID: 'id' }; - const expectedToken = { Namespace: 'default', AccessorID: null, SecretID: item.ID }; + const expected = { Namespace: 'default', AccessorID: null, SecretID: item.ID }; this.owner.register( 'service:settings', Service.extend({ persist: function(actual) { - assert.deepEqual(actual.token, expectedToken); + assert.deepEqual(actual.token, expected); return Promise.resolve(actual); }, }) ); const subject = this.subject(); - const expected = 'dc.services'; - const transitionTo = this.stub(subject, 'transitionTo').returnsArg(0); return subject.actions.use .bind(subject)(item) .then(function(actual) { - assert.ok(transitionTo.calledOnce); - assert.equal(actual, expected); + assert.deepEqual(actual.token, expected); }); }); test('clone clones the token and calls afterDelete correctly', function(assert) {