ui: Logout button (#7604)

* ui: Logout button

This commit adds an easier way to logout of the UI using a logout button

Notes:

- Added a Logout button to the main navigation when you are logged in,
meaning you have easy access to a way to log out of the UI.
- Changed all wording to use 'Log in/out' vocabulary instad of 'stop
using'.
- The logout button opens a panel to show you your current ACL
token and a logout button in order to logout.
- When using legacy ACLs we don't show the current ACL token as legacy
ACLs tokens only have secret values, whereas the new ACLs use a
non-secret ID plus a secret ID (that we don't show).
- We also added a new `<EmptyState />` component to use for all our
empty states. We currently only use this for the ACLs disabled screen to
provide more outgoing links to more readind material/documentation to
help you to understand and enable ACLs.
- The `<DataSink />` component is the sibling to our `<DataSource />`
component and whilst is much simpler (as it doesn't require polling
support), its tries to use the same code patterns for consistencies
sake.
- We had a fun problem with ember-data's `store.unloadAll` here, and in
the end went with `store.init` to empty the ember-data store instead due
to timing issues.
- We've tried to use already existing patterns in the Consul UI here
such as our preexisting `feedback` service, although these are likely to
change in the future. The thinking here is to add this feature with as
little change as possible.

Overall this is a precursor to a much larger piece of work centered on
auth in the UI. We figured this was a feature complete piece of work as
it is and thought it was worthwhile to PR as a feature on its own, which
also means the larger piece of work will be a smaller scoped PR also.
This commit is contained in:
John Cowen 2020-04-08 18:03:18 +01:00 committed by John Cowen
parent 17f10ffd0d
commit e34c16a90c
47 changed files with 730 additions and 183 deletions

View File

@ -3,14 +3,45 @@
<header>
{{#each flashMessages.queue as |flash|}}
<FlashMessage @flash={{flash}} as |component flash|>
{{! flashes automatically ucfirst the type }}
{{#let (lowercase component.flashType) (lowercase flash.action) as |status type|}}
{{! flashes automatically ucfirst the type }}
<p data-notification class="{{lowercase component.flashType}} notification-{{lowercase flash.action}}">
<strong>
{{component.flashType}}!
</strong>
<YieldSlot @name="notification" @params={{block-params (lowercase component.flashType) (lowercase flash.action) flash.item}}>{{yield}}</YieldSlot>
</p>
<p data-notification class={{concat status ' notification-' type}}>
<strong>
{{capitalize status}}!
</strong>
{{#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}}
</p>
{{/let}}
</FlashMessage>
{{/each}}
<div>

View File

@ -0,0 +1,62 @@
## DataSink
```handlebars
<DataSink
@sink="/dc/nspace/intentions/{{intentions.uid}}"
@onchange={{action (mut items) value="data"}}
@onerror={{action (mut error) value="error"}}
as |api|
></DataSink>
```
### 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
<DataSink @src="/dc/nspace/intentions/{{intention.uid}}"
@onchange={{action (mut item) value="data"}}
@onerror={{action (mut error) value="error"}}
as |api|
>
<button type="button" onclick={{action api.open (hash Name="New Name")}}>Create/Update</button>
<button type="button" onclick={{action api.open null}}>Delete</button>
</DataSink>
{{item.Name}}
```
```handlebars
<DataSink @src="/dc/nspace/intentions/{{intention.uid}}"
@data=(hash Name="New Name")
@onchange={{action (mut item) value="data"}}
@onerror={{action (mut error) value="error"}}
></DataSink>
{{item.Name}}
```
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,4 @@
{{yield (hash
open=(action 'open')
state=state
)}}

View File

@ -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);
}
},
},
});

View File

@ -0,0 +1,21 @@
{{yield}}
<div class="empty-state" ...attributes>
<header>
{{#yield-slot name="header"}}
{{yield}}
{{/yield-slot}}
{{#yield-slot name="subheader"}}
{{yield}}
{{/yield-slot}}
</header>
<p>
{{#yield-slot name="body"}}
{{yield}}
{{/yield-slot}}
</p>
{{#yield-slot name="actions"}}
<ul>
{{yield}}
</ul>
{{/yield-slot}}
</div>

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
import Slotted from 'block-slots';
export default Component.extend(Slotted, {
tagName: '',
});

View File

@ -111,6 +111,44 @@
<li data-test-main-nav-settings class={{if (is-href 'settings') 'is-active'}}>
<a href={{href-to 'settings'}}>Settings</a>
</li>
{{#if (env 'CONSUL_ACLS_ENABLED')}}
<DataSource
@src="settings://consul:token"
@onchange={{action "changeToken" value="data"}}
/>
<DataSink
@sink="settings://consul:token"
as |tokenSink|>
{{#if (not-eq token.AccessorID undefined)}}
<li data-test-main-nav-auth>
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Logout
</BlockSlot>
<BlockSlot @name="menu">
{{#if token.AccessorID}}
<li role="none">
<dl>
<dt>
<span>My ACL Token</span><br />
AccessorID
</dt>
<dd>
{{substr token.AccessorID -8}}
</dd>
</dl>
</li>
<li role="separator"></li>
{{/if}}
<li class="dangerous" role="none">
<button type="button" tabindex="-1" role="menuitem" onclick={{action tokenSink.open null}}>Logout</button>
</li>
</BlockSlot>
</PopoverMenu>
</li>
{{/if}}
</DataSink>
{{/if}}
</ul>
</nav>
</div>

View File

@ -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();

View File

@ -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(() => {

View File

@ -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;

View File

@ -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';

View File

@ -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');
},
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,3 +1,7 @@
%with-popover-menu {
position: relative;
}
%more-popover-menu {
@extend %display-toggle-siblings;
}

View File

@ -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'] {

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
@import './empty-state/index';
.empty-state {
@extend %empty-state;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,5 +1,5 @@
@import './feedback-dialog/index';
main .with-feedback {
.with-feedback {
@extend %feedback-dialog-inline;
}
%feedback-dialog-inline .feedback-dialog-out {

View File

@ -26,6 +26,7 @@
@import './sort-control';
@import './discovery-chain';
@import './consul-intention-list';
@import './empty-state';
@import './tabular-details';
@import './tabular-collection';

View File

@ -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;

View File

@ -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,

View File

@ -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;
}

View File

@ -5,9 +5,9 @@
<fieldset>
<label class="type-text">
<span>SecretID or Token</span>
<Input @type="password" @value={{secret}} @name="secret" placeholder="SecretID or Token" />
<input type="password" name="secret" placeholder="SecretID or Token" oninput={{action (mut secret) value="target.value"}} />
</label>
</fieldset>
{{! authorize is in the routes/dc/acls.js route }}
<button type="submit" {{action 'authorize' secret nspace}}>Save</button>
<button type="submit" {{action 'authorize' secret nspace}}>Login</button>
</form>

View File

@ -1,6 +1,18 @@
<p>
ACLs are disabled in this Consul cluster. This is the default behavior, as you have to explicitly enable them.
</p>
<p>
Learn more in the <a href="{{ env 'CONSUL_DOCS_URL'}}/guides/acl.html" rel="noopener noreferrer" target="_blank">ACL documentation</a>
</p>
<EmptyState>
<BlockSlot @name="header">
<h2>Welcome to ACLs</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
ACLs are not enabled. We strongly encourage the use of ACLs in production environments for the best security practices.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/acl/index.html" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/security-networking/production-acls" rel="noopener noreferrer" target="_blank">Follow the guide</a>
</li>
</BlockSlot>
</EmptyState>

View File

@ -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.

View File

@ -7,7 +7,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "policy " (if (or isAuthorized isEnabled) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class={{concat "policy " (if isAuthorized "edit" "list")}}
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/acls/policies/notifications'}}
</BlockSlot>

View File

@ -3,7 +3,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "policy " (if (not isAuthorized) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class="policy list"
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/acls/policies/notifications'}}
</BlockSlot>

View File

@ -7,7 +7,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "role " (if (or isAuthorized isEnabled) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class={{concat "role " (if isAuthorized "edit" "list")}}
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/acls/roles/notifications'}}
</BlockSlot>

View File

@ -3,7 +3,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "role " (if (not isAuthorized) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class="role list"
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/acls/roles/notifications'}}
</BlockSlot>

View File

@ -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

View File

@ -7,7 +7,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "token " (if (or isAuthorized isEnabled) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class={{concat "token " (if isAuthorized "edit" "list")}}
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/acls/tokens/notifications'}}
</BlockSlot>

View File

@ -3,7 +3,12 @@
{{else}}
{{title 'Access Controls'}}
{{/if}}
<AppView @class={{concat "token " (if (and isEnabled (not isAuthorized)) "edit" "list")}} @loading={{isLoading}} @authorized={{isAuthorized}} @enabled={{isEnabled}}>
<AppView
@class="token list"
@loading={{isLoading}}
@authorized={{isAuthorized}}
@enabled={{isEnabled}}
>
<BlockSlot @name="notification" as |status type subject|>
{{partial 'dc/acls/tokens/notifications'}}
</BlockSlot>
@ -81,8 +86,8 @@
</li>
{{/if}}
{{#if (eq item.AccessorID token.AccessorID) }}
<li role="none">
<label for={{concat confirm 'logout'}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-logout>Stop using</label>
<li role="none" class="dangerous">
<label for={{concat confirm 'logout'}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-logout>Log out</label>
<div role="menu">
<div class="confirmation-alert warning">
<div>

View File

@ -1,8 +1,5 @@
{{title 'Services'}}
<AppView @class="service list">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}}
</BlockSlot>
<BlockSlot @name="header">
<h1>
Services <em>{{format-number items.length}} total</em>

View File

@ -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
---

View File

@ -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

View File

@ -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);

View File

@ -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) {