UI: Improved Login/Logout flow inc SSO support (#7790)

* 6 new components for new login/logout flow, plus SSO support

UI Components:

1. AuthDialog: Wraps/orchestrates AuthForm and AuthProfile
2. AuthForm: Authorization form shown when logged out.
3. AuthProfile: Simple presentational component to show the users
'Profile'
4. OidcSelect: A 'select' component for selecting an OIDC provider,
dynamically uses either a single select menu or multiple buttons
depending on the amount of providers

Data Components:

1. JwtSource: Given an OIDC provider URL this component will request a
token from the provider and fire an donchange event when it has been
retrieved. Used by TokenSource.
2. TokenSource: Given a oidc provider name or a Consul SecretID,
TokenSource will use whichever method/API requests required to retrieve
Consul ACL Token, which is emitted to the onchange event handler.

Very basic README documentation included here, which is likely to be
refined somewhat.

* CSS required for new auth/SSO UI components

* Remaining app code required to tie the new auth/SSO work together

* CSS code required to help tie the auth/SSO work together

* Test code in order to get current tests passing with new auth/SSO flow

..plus extremely basics/skipped rendering tests for the new components

* Treat the secret received from the server as the truth

Previously we've always treated what the user typed as the truth, this
breaks down when using SSO as the user doesn't type anything to retrieve
a token. Therefore we change this so that we use the secret in the API
response as the truth.

* Make sure removing an dom tree from a buffer only removes its own tree
This commit is contained in:
John Cowen 2020-05-11 16:37:11 +01:00 committed by John Cowen
parent e4d27bc134
commit 412eec7f5d
99 changed files with 1576 additions and 344 deletions

View File

@ -33,7 +33,7 @@ export default Adapter.extend({
${{
...Namespace(ns),
AuthMethod: id,
RedirectURI: `${this.env.var('CONSUL_BASE_UI_URL')}/torii/redirect.html`,
RedirectURI: `${this.env.var('CONSUL_BASE_UI_URL')}/oidc/callback`,
}}
`;
},

View File

@ -0,0 +1,56 @@
## AuthDialog
```handlebars
<AuthDialog @dc={{dc}} @nspace={{}} @onchange={{action 'change'}} as |api components|>
{{#let components.AuthForm components.AuthProfile as |AuthForm AuthProfile|}}
<BlockSlot @name="unauthorized">
Here's the login form:
<AuthForm />
</BlockSlot>
<BlockSlot @name="authorized">
Here's your profile:
<AuthProfile />
<button onclick={{action api.logout}} />
</BlockSlot>
{{/let}}
</AuthDialog>
```
### Arguments
A component to help orchestrate a login/logout flow.
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `dc` | `String` | | The name of the current datacenter |
| `nspace` | `String` | | The name of the current namespace |
| `onchange` | `Function` | | An action to fire when the users token has changed (logged in/logged out/token changed) |
### Methods/Actions/api
| Method/Action | Description |
| --- | --- |
| `login` | Login with a specified token |
| `logout` | Logout (delete token) |
| `token` | The current token itself (as a property not a method) |
### Components
| Name | Description |
| --- | --- |
| [`AuthForm`](../auth-form/README.mdx) | Renders an Authorization form |
| [`AuthProfile`](../auth-profile/README.mdx) | Renders a User Profile |
### Slots
| Name | Description |
| --- | --- |
| `unauthorized` | This slot is only rendered when the user doesn't have a token |
| `authorized` | This slot is only rendered whtn the user has a token.|
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,34 @@
export default {
id: 'auth-dialog',
initial: 'idle',
on: {
CHANGE: [
{
target: 'authorized',
cond: 'hasToken',
actions: ['login'],
},
{
target: 'unauthorized',
actions: ['logout'],
},
],
},
states: {
idle: {
on: {
CHANGE: [
{
target: 'authorized',
cond: 'hasToken',
},
{
target: 'unauthorized',
},
],
},
},
unauthorized: {},
authorized: {},
},
};

View File

@ -0,0 +1,40 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
<Guard @name="hasToken" @cond={{action 'hasToken'}} />
<Action @name="login" @exec={{action 'login'}} />
<Action @name="logout" @exec={{action 'logout'}} />
{{! This DataSource just permanently listens to any changes to the users }}
{{! token, whether thats a new token, a changed token or a deleted token }}
<DataSource
@src="settings://consul:token"
@onchange={{queue (action (mut token) value="data") (action dispatch "CHANGE") (action (mut previousToken) value="data")}}
/>
{{! This DataSink is just used for logging in from the form, }}
{{! or logging out via the exposed logout function }}
<DataSink
@sink="settings://consul:token"
as |sink|
>
{{yield}}
{{#let (hash
login=(action sink.open)
logout=(action sink.open null)
token=token
) (hash
AuthProfile=(component 'auth-profile' item=token)
AuthForm=(component 'auth-form' dc=dc nspace=nspace onsubmit=(action sink.open value="data"))
) as |api components|}}
<State @matches="authorized">
{{#yield-slot name="authorized"}}
{{yield api components}}
{{/yield-slot}}
</State>
<State @matches="unauthorized">
{{#yield-slot name="unauthorized"}}
{{yield api components}}
{{/yield-slot}}
</State>
{{/let}}
</DataSink>
</StateChart>

View File

@ -0,0 +1,42 @@
import Component from '@ember/component';
import Slotted from 'block-slots';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import chart from './chart.xstate';
export default Component.extend(Slotted, {
tagName: '',
repo: service('repository/oidc-provider'),
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
hasToken: function() {
return typeof this.token.AccessorID !== 'undefined';
},
login: function() {
let prev = get(this, 'previousToken.AccessorID');
let current = get(this, 'token.AccessorID');
if (prev === null) {
prev = get(this, 'previousToken.SecretID');
}
if (current === null) {
current = get(this, 'token.SecretID');
}
let type = 'authorize';
if (typeof prev !== 'undefined' && prev !== current) {
type = 'use';
}
this.onchange({ data: get(this, 'token'), type: type });
},
logout: function() {
if (typeof get(this, 'previousToken.AuthMethod') !== 'undefined') {
// we are ok to fire and forget here
this.repo.logout(get(this, 'previousToken.SecretID'));
}
this.previousToken = null;
this.onchange({ data: null, type: 'logout' });
},
},
});

View File

@ -0,0 +1,18 @@
## AuthForm
```handlebars
<AuthForm as |api|></AuthForm>
```
### Methods/Actions/api
| Method/Action | Description |
| --- | --- |
| `reset` | Reset the form back to its original empty/non-error state |
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,55 @@
export default {
id: 'auth-form',
initial: 'idle',
on: {
RESET: [
{
target: 'idle',
},
],
},
states: {
idle: {
entry: ['clearError'],
on: {
SUBMIT: [
{
target: 'loading',
cond: 'hasValue',
},
{
target: 'error',
},
],
},
},
loading: {
on: {
ERROR: [
{
target: 'error',
},
],
},
},
error: {
exit: ['clearError'],
on: {
TYPING: [
{
target: 'idle',
},
],
SUBMIT: [
{
target: 'loading',
cond: 'hasValue',
},
{
target: 'error',
},
],
},
},
},
};

View File

@ -0,0 +1,100 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
{{yield (hash
reset=(action dispatch "RESET")
focus=(action 'focus')
)}}
<Guard @name="hasValue" @cond={{action 'hasValue'}} />
{{!FIXME: Call this reset or similar }}
<Action @name="clearError" @exec={{queue (action (mut error) undefined) (action (mut secret) undefined)}} />
<div class="auth-form" ...attributes>
<State @matches="error">
{{#if error.status}}
<p role="alert" class="notice error">
{{#if value.Name}}
{{#if (eq error.status '403')}}
<strong>Consul login failed</strong><br />
We received a token from your OIDC provider but could not log in to Consul with it.
{{else if (eq error.status '401')}}
<strong>Could not log in to provider</strong><br />
The OIDC provider has rejected this access token. Please have an administrator check your auth method configuration.
{{else if (eq error.status '499')}}
<strong>SSO log in window closed</strong><br />
The OIDC provider window was closed. Please try again.
{{else}}
<strong>Error</strong><br />
{{error.detail}}
{{/if}}
{{else}}
{{#if (eq error.status '403')}}
<strong>Invalid token</strong><br />
The token entered does not exist. Please enter a valid token to log in.
{{else}}
<strong>Error</strong><br />
{{error.detail}}
{{/if}}
{{/if}}
</p>
{{/if}}
</State>
<form onsubmit={{action dispatch "SUBMIT"}}>
<fieldset>
<label class={{concat "type-password" (if (and (state-matches state 'error') (not error.status)) ' has-error' '')}}>
<span>Log in with a token</span>
<input
{{ref this 'input'}}
disabled={{state-matches state "loading"}}
type="password"
name="auth[SecretID]"
placeholder="SecretID"
value={{secret}}
oninput={{queue
(action (mut secret) value="target.value")
(action (mut value) value="target.value")
(action dispatch "TYPING")
}}
/>
<State @matches="error">
{{#if (not error.status)}}
<strong role="alert">
Please enter your secret
</strong>
{{/if}}
</State>
</label>
</fieldset>
<button type="submit" disabled={{state-matches state "loading"}}>
Log in
</button>
<em>Contact your administrator for login credentials.</em>
</form>
{{#if (env 'CONSUL_SSO_ENABLED')}}
<DataSource
@src={{concat '/' (or nspace 'default') '/' dc '/oidc/providers'}}
@onchange={{queue (action (mut providers) value="data")}}
@onerror={{queue (action (mut error) value="error.errors.firstObject")}}
@loading="lazy"
/>
{{#if (gt providers.length 0)}}
<p>
<span>or</span>
</p>
{{/if}}
<OidcSelect
@items={{providers}}
@disabled={{state-matches state "loading"}}
@onchange={{queue (action (mut value)) (action dispatch "SUBMIT") }}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
{{/if}}
</div>
<State @matches="loading">
<TokenSource
@dc={{dc}}
@nspace={{nspace}}
@type={{if value.Name 'oidc' 'secret'}}
@value={{if value.Name value.Name value}}
@onchange={{action onsubmit}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
</State>
</StateChart>

View File

@ -0,0 +1,21 @@
import Component from '@ember/component';
import chart from './chart.xstate';
export default Component.extend({
tagName: '',
onsubmit: function(e) {},
onchange: function(e) {},
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
hasValue: function(context, event, meta) {
return this.value !== '' && typeof this.value !== 'undefined';
},
focus: function() {
this.input.focus();
},
},
});

View File

@ -0,0 +1,20 @@
## AuthProfile
```handlebars
<AuthProfile @item={{token}} />
```
A straightforward partial-like component for rendering a user profile.
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `item` | `Object` | | A Consul shaped token object (currently only requires an AccessorID property to be set |
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,9 @@
<dl>
<dt>
<span>My ACL Token</span><br />
AccessorID
</dt>
<dd>
{{substr item.AccessorID -8}}
</dd>
</dl>

View File

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

View File

@ -1,3 +1,4 @@
<div ...attributes>
{{yield}}
<YieldSlot @name="create">{{yield}}</YieldSlot>
<label class="type-text">
@ -24,4 +25,5 @@
<YieldSlot @name="set">{{yield}}</YieldSlot>
{{else}}
{{/if}}
{{/if}}
</div>

View File

@ -8,6 +8,7 @@ import WithListeners from 'consul-ui/mixins/with-listeners';
export default Component.extend(SlotsMixin, WithListeners, {
onchange: function() {},
tagName: '',
error: function() {},
type: '',

View File

@ -9,10 +9,11 @@ export default Component.extend({
},
didInsertElement: function() {
this._super(...arguments);
this.buffer.add(this.getBufferName(), this.element);
this._element = this.buffer.add(this.getBufferName(), this.element);
},
didDestroyElement: function() {
this._super(...arguments);
this.buffer.remove(this.getBufferName());
this.buffer.remove(this.getBufferName(), this._element);
this._element = null;
},
});

View File

@ -6,6 +6,7 @@ import WithListeners from 'consul-ui/mixins/with-listeners';
// match anything that isn't a [ or ] into multiple groups
const propRe = /([^[\]])+/g;
export default Component.extend(WithListeners, SlotsMixin, {
tagName: '',
onreset: function() {},
onchange: function() {},
onerror: function() {},

View File

@ -1,4 +1,4 @@
<ModalLayer />
<header role="banner" data-test-navigation>
<a data-test-main-nav-logo href={{href-to 'index'}}><svg width="28" height="27" xmlns="http://www.w3.org/2000/svg"><title>Consul</title><path d="M13.284 16.178a2.876 2.876 0 1 1-.008-5.751 2.876 2.876 0 0 1 .008 5.75zm5.596-1.547a1.333 1.333 0 1 1 0-2.667 1.333 1.333 0 0 1 0 2.667zm4.853 1.249a1.271 1.271 0 1 1 .027-.107c0 .031 0 .067-.027.107zm-.937-3.436a1.333 1.333 0 1 1 .986-1.595c.033.172.033.348 0 .52-.07.53-.465.96-.986 1.075zm4.72 3.29a1.333 1.333 0 1 1-1.076-1.538 1.333 1.333 0 0 1 1.116 1.417.342.342 0 0 0-.027.12h-.013zm-1.08-3.33a1.333 1.333 0 1 1 1.088-1.524c.014.114.014.229 0 .342a1.333 1.333 0 0 1-1.102 1.182h.014zm-.925 7.925a1.333 1.333 0 1 1 .165-.547c-.01.193-.067.38-.165.547zm-.48-12.191a1.333 1.333 0 1 1 .507-1.814c.14.237.198.514.164.787-.038.438-.289.828-.67 1.045v-.018zM13.333 26.667C5.97 26.667 0 20.697 0 13.333 0 5.97 5.97 0 13.333 0c2.929-.01 5.778.955 8.098 2.742L19.8 4.89a10.667 10.667 0 0 0-17.133 8.444 10.667 10.667 0 0 0 17.137 8.471l1.627 2.13a13.218 13.218 0 0 1-8.098 2.733z" fill="#FFF"/></svg></a>
<input type="checkbox" name="menu" id="main-nav-toggle" onchange={{action 'change'}} />
@ -100,7 +100,7 @@
</nav>
<nav>
<ul>
<li data-test-main-nav-docs>
<li data-test-main-nav-help>
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Help
@ -123,42 +123,55 @@
<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
<li data-test-main-nav-auth>
<AuthDialog
@dc={{dc.Name}}
@nspace={{nspace.Name}}
@onchange={{action onchange}} as |authDialog components|
>
{{#let components.AuthForm components.AuthProfile as |AuthForm AuthProfile|}}
<BlockSlot @name="unauthorized">
<label tabindex="0" for="login-toggle" onkeypress={{action 'keypressClick'}}>
<span>Log in</span>
</label>
<ModalDialog @name="login-toggle" @onclose={{action 'close'}} @onopen={{action 'open'}}>
<BlockSlot @name="header">
<h2>Log in to Consul</h2>
</BlockSlot>
<BlockSlot @name="body">
<AuthForm as |api|>
<Ref @target={{this}} @name="authForm" @value={{api}} />
</AuthForm>
</BlockSlot>
<BlockSlot @name="actions" as |close|>
<button type="button" onclick={{action close}}>
Continue without logging in
</button>
</BlockSlot>
</ModalDialog>
</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 @name="authorized">
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Logout
</BlockSlot>
<BlockSlot @name="menu">
{{!TODO: It might be nice to use one of our recursive components here}}
{{#if authDialog.token.AccessorID}}
<li role="none">
<AuthProfile />
</li>
<li role="separator"></li>
{{/if}}
<li class="dangerous" role="none">
<button type="button" tabindex="-1" role="menuitem" onclick={{action authDialog.logout}}>Logout</button>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</PopoverMenu>
</li>
{{/if}}
</DataSink>
{{/let}}
</AuthDialog>
</li>
{{/if}}
</ul>
</nav>
@ -172,5 +185,4 @@
<p data-test-footer-version>Consul {{env 'CONSUL_VERSION'}}</p>
<a data-test-footer-docs href="{{env 'CONSUL_DOCS_URL'}}" rel="help noopener noreferrer" target="_blank">Documentation</a>
{{{concat '<!-- ' (env 'CONSUL_GIT_SHA') '-->'}}}
</footer>
<ModalLayer />
</footer>

View File

@ -1,19 +1,12 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { get, set, computed } from '@ember/object';
import { getOwner } from '@ember/application';
import { computed } from '@ember/object';
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._super(...arguments);
this.dom.root().classList.remove('template-with-vertical-menu');
},
// TODO: Right now this is the only place where we need permissions
@ -25,108 +18,15 @@ 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.Name) {
if (!routeName.startsWith('nspace')) {
routeName = `nspace.${routeName}`;
}
const nspace = get(token, 'Namespace');
// you potentially have a new namespace
if (typeof nspace !== 'undefined') {
return route.transitionTo(`${routeName}`, `~${nspace}`, this.dc.Name);
}
// you are logging out, just refresh
return route.refresh();
} 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 || []);
keypressClick: function(e) {
e.target.dispatchEvent(new MouseEvent('click'));
},
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,
},
]);
open: function() {
this.authForm.focus();
},
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;
},
{}
);
close: function() {
this.authForm.reset();
},
change: function(e) {
const win = this.dom.viewport();

View File

@ -0,0 +1,24 @@
## JwtSource
```handlebars
<JwtSource @src={{url}} @onchange={{action 'change'}} @onerror={{action 'error'}} />
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `String` | | The source to subscribe to updates to, this should map to a string based URI |
| `onchange` | `Function` | | The action to fire when the data changes. Emits an Event-like object with a `data` property containing the jwt data, in this case the autorizationCode and the status |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |
This component will go through the steps of requesting a JWT token from a third party oauth provider. `src` should contain the full URL of the authorization URL for the 3rd party provider. Once the user has logged into the 3rd party provider the `onchange` event will be fired containing an event-like object whose data contains the JWT information.
The component need only be place into the DOM in order to begin the OAuth dance.
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,31 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { fromPromise } from 'consul-ui/utils/dom/event-source';
export default Component.extend({
repo: service('repository/oidc-provider'),
dom: service('dom'),
tagName: '',
onchange: function(e) {},
onerror: function(e) {},
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
},
willDestroy: function() {
this._super(...arguments);
this.repo.close();
this._listeners.remove();
},
didInsertElement: function() {
if (this.source) {
this.source.close();
}
// TODO: Could this use once? Double check but I don't think it can
this.source = fromPromise(this.repo.findCodeByURL(this.src));
this._listeners.add(this.source, {
message: e => this.onchange(e),
error: e => this.onerror(e),
});
},
});

View File

@ -0,0 +1,16 @@
export default {
id: 'oidc-select',
initial: 'loading',
states: {
loaded: {},
loading: {
on: {
SUCCESS: [
{
target: 'loaded',
},
],
},
},
},
};

View File

@ -0,0 +1,46 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
<Ref @target={{this}} @name="dispatch" @value={{dispatch}} />
<State @matches="loaded">
<div class="oidc-select" ...attributes>
{{#if (lt items.length 3)}}
<ul>
{{#each items as |item|}}
<li>
<button
disabled={{disabled}}
type="button" class={{concat item.Kind '-oidc-provider'}}
onclick={{action onchange item}}
>
Continue with {{or item.DisplayName item.Name}}
</button>
</li>
{{/each}}
</ul>
{{else}}
{{#let (or provider (object-at 0 items)) as |item|}}
<label>
<span>SSO Provider</span>
<PowerSelect
@disabled={{disabled}}
@onChange={{action (mut provider)}}
@selected={{item}}
@searchEnabled={{false}}
@options={{items}} as |item|>
<span class={{concat item.Kind '-oidc-provider'}}>{{or item.DisplayName item.Name}}</span>
</PowerSelect>
</label>
<button
disabled={{disabled}}
type="button"
onclick={{action onchange item}}
>
Log in
</button>
{{/let}}
{{/if}}
</div>
</State>
<State @matches="loading">
<div class="progress indeterminate"></div>
</State>
</StateChart>

View File

@ -0,0 +1,20 @@
import Component from '@ember/component';
import chart from './chart.xstate';
export default Component.extend({
tagName: '',
onchange: function() {},
onerror: function() {},
init: function() {
this._super(...arguments);
this.chart = chart;
},
didReceiveAttrs: function() {
// This gets called no matter which attr has changed
// such as disabled, thing is once we are in loaded state
// it doesn't do anything anymore
if (typeof this.items !== 'undefined') {
this.dispatch('SUCCESS');
}
},
});

View File

@ -1,4 +1,4 @@
<ChildSelector @repo={{repo}} @dc={{dc}} @nspace={{nspace}} @type="policy" @placeholder="Search for policy" @items={{items}}>
<ChildSelector ...attributes @repo={{repo}} @dc={{dc}} @nspace={{nspace}} @type="policy" @placeholder="Search for policy" @items={{items}}>
{{yield}}
<BlockSlot @name="label">
Apply an existing policy

View File

@ -1,5 +1,5 @@
{{yield}}
<fieldset>
<fieldset class="role-form" data-test-role-form>
<label class="type-text{{if item.error.Name ' has-error'}}">
<span>Name</span>
<input type="text" value={{item.Name}} name="role[Name]" autofocus="autofocus" oninput={{action 'change'}} />

View File

@ -0,0 +1,32 @@
## TokenSource
```handlebars
<TokenSource
@dc={{dc}}
@nspace={{nspace}}
@type={{or 'oidc' 'secret'}}
@value={{or identifierForProvider secret}}
@onchange={{action 'change'}}
@onerror={{action 'error'}}
/>
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `dc` | `String` | | The name of the current datacenter |
| `nspace` | `String` | | The name of the current namespace |
| `onchange` | `Function` | | The action to fire when the data changes. Emits an Event-like object with a `data` property containing the jwt data, in this case the autorizationCode and the status |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |
This component will go through the steps of requesting a JWT token from a third party oauth provider. `src` should contain the full URL of the authorization URL for the 3rd party provider. Once the user has logged into the 3rd party provider the `onchange` event will be fired containing an event-like object whose data contains the JWT information.
The component need only be place into the DOM in order to begin the OAuth dance.
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,30 @@
export default {
id: 'token-source',
initial: 'idle',
on: {
RESTART: [
{
target: 'secret',
cond: 'isSecret',
},
{
target: 'provider',
},
],
},
states: {
idle: {},
secret: {},
provider: {
on: {
SUCCESS: 'jwt',
},
},
jwt: {
on: {
SUCCESS: 'token',
},
},
token: {},
},
};

View File

@ -0,0 +1,33 @@
<StateChart @src={{chart}} @initial={{if (eq type 'oidc') 'provider' 'secret'}} as |State Guard Action dispatch state|>
<Guard @name="isSecret" @cond={{action 'isSecret'}} />
{{#let (concat '/' (or nspace 'default') '/' dc) as |path|}}
<State @matches="secret">
<DataSource
@src={{concat path '/token/self/' value}}
@onchange={{action 'change'}}
@onerror={{action onerror}}
/>
</State>
<State @matches="provider">
<DataSource
@src={{concat path '/oidc/provider/' value}}
@onchange={{queue (action (mut provider) value="data") (action dispatch "SUCCESS")}}
@onerror={{action onerror}}
/>
</State>
<State @matches="jwt">
<JwtSource
@src={{provider.AuthURL}}
@onchange={{queue (action (mut jwt) value="data") (action dispatch "SUCCESS")}}
@onerror={{action onerror}}
/>
</State>
<State @matches="token">
<DataSource
@src={{concat path '/oidc/authorize/' provider.Name '/' jwt.authorizationCode '/' jwt.authorizationState}}
@onchange={{action 'change'}}
@onerror={{action onerror}}
/>
</State>
{{/let}}
</StateChart>

View File

@ -0,0 +1,36 @@
import Component from '@ember/component';
import chart from './chart.xstate';
export default Component.extend({
onchange: function() {},
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
isSecret: function() {
return this.type === 'secret';
},
change: function(e) {
e.data.toJSON = function() {
return {
AccessorID: this.AccessorID,
// TODO: In the past we've always ignored the SecretID returned
// from the server and used what the user typed in instead, now
// as we don't know the SecretID when we use SSO we use the SecretID
// in the response
SecretID: this.SecretID,
Namespace: this.Namespace,
...{
AuthMethod: typeof this.AuthMethod !== 'undefined' ? this.AuthMethod : undefined,
// TODO: We should be able to only set namespaces if they are enabled
// but we might be testing for nspaces everywhere
// Namespace: typeof this.Namespace !== 'undefined' ? this.Namespace : undefined
},
};
};
// FIXME: We should probably put the component into idle state
this.onchange(e);
},
},
});

View File

@ -1,3 +1,63 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { get } from '@ember/object';
import transitionable from 'consul-ui/utils/routing/transitionable';
export default Controller.extend({});
export default Controller.extend({
router: service('router'),
http: service('repository/type/event-source'),
dataSource: service('data-source/service'),
client: service('client/http'),
store: service('store'),
feedback: service('feedback'),
actions: {
// TODO: We currently do this in the controller instead of the router
// as the nspace and dc variables aren't available directly on the Route
// look to see if we can move this up there so we can empty the Controller
// out again
reauthorize: function(e) {
// TODO: For the moment e isn't a real event
// it has data which is potentially the token
// and type which is the logout/authorize/use action
// used for the feedback service.
this.feedback.execute(
() => {
// TODO: Centralize this elsewhere
this.client.abort();
this.http.resetCache();
this.dataSource.resetCache();
this.store.init();
const params = {};
if (e.data) {
const token = e.data;
// TODO: Do I actually need to check to see if nspaces are enabled here?
if (typeof this.nspace !== 'undefined') {
const nspace = get(token, 'Namespace') || this.nspace.Name;
// you potentially have a new namespace
// if you do redirect to it
if (nspace !== this.nspace.Name) {
params.nspace = `~${nspace}`;
}
}
}
const routeName = this.router.currentRoute.name;
const route = getOwner(this).lookup(`route:${routeName}`);
// We use transitionable here as refresh doesn't work if you are on an error page
// which is highly likely to happen here (403s)
if (routeName !== this.router.currentRouteName) {
return route.transitionTo(...transitionable(this.router, params, getOwner(this)));
} else {
return route.refresh();
}
},
e.type,
function(type, e) {
return type;
},
{}
);
},
},
});

View File

@ -18,7 +18,9 @@ export default Route.extend(WithBlockingActions, {
return hash({
router: this.router,
dcs: this.repo.findAll(),
nspaces: this.nspacesRepo.findAll(),
nspaces: this.nspacesRepo.findAll().catch(function() {
return [];
}),
// these properties are added to the controller from route/dc
// as we don't have access to the dc and nspace params in the URL
@ -72,30 +74,6 @@ export default Route.extend(WithBlockingActions, {
error = e.errors[0];
error.message = error.title || error.detail || 'Error';
}
// TODO: Unfortunately ember will not maintain the correct URL
// for you i.e. when this happens the URL in your browser location bar
// will be the URL where you clicked on the link to come here
// not the URL where you got the 403 response
// Currently this is dealt with a lot better with the new ACLs system, in that
// if you get a 403 in the ACLs area, the URL is correct
// Moving that app wide right now wouldn't be ideal, therefore simply redirect
// to the ACLs URL instead of maintaining the actual URL, which is better than the old
// 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.settings.persist({
token: {
AccessorID: null,
SecretID: null,
Namespace: null,
},
});
}
if (error.status === '') {
error.message = 'Error';
}
@ -136,16 +114,12 @@ export default Route.extend(WithBlockingActions, {
removeLoading($root);
// we can't use setupController as we received an error
// so we do it manually instead
next(() => {
this.controllerFor('application').setProperties(model);
this.controllerFor('error').setProperties({ error: error });
});
this.controllerFor('application').setProperties(model);
this.controllerFor('error').setProperties({ error: error });
})
.catch(e => {
removeLoading($root);
next(() => {
this.controllerFor('error').setProperties({ error: error });
});
this.controllerFor('error').setProperties({ error: error });
});
return true;
},

View File

@ -8,6 +8,7 @@ export default Service.extend({
policies: service('repository/policy'),
policy: service('repository/policy'),
roles: service('repository/role'),
oidc: service('repository/oidc-provider'),
type: service('data-source/protocols/http/blocking'),
source: function(src, configuration) {
// TODO: Consider adding/requiring nspace, dc, model, action, ...rest
@ -29,6 +30,7 @@ export default Service.extend({
return event;
};
}
let method, slug;
switch (model) {
case 'datacenters':
find = configuration => repo.findAll(configuration);
@ -46,6 +48,21 @@ export default Service.extend({
case 'policy':
find = configuration => repo.findBySlug(rest[0], dc, nspace, configuration);
break;
case 'oidc':
[method, ...slug] = rest;
switch (method) {
case 'providers':
find = configuration => repo.findAllByDatacenter(dc, nspace, configuration);
break;
case 'provider':
find = configuration => repo.findBySlug(slug[0], dc, nspace);
break;
case 'authorize':
find = configuration =>
repo.authorize(slug[0], slug[1], slug[2], dc, nspace, configuration);
break;
}
break;
}
return this.type.source(find, configuration);
},

View File

@ -5,11 +5,11 @@ import MultiMap from 'mnemonist/multi-map';
// TODO: Expose sizes of things via env vars
// caches cursors and previous events when the EventSources are destroyed
let cache;
let cache = null;
// keeps a record of currently in use EventSources
let sources;
let sources = null;
// keeps a count of currently in use EventSources
let usage;
let usage = null;
export default Service.extend({
dom: service('dom'),
@ -18,10 +18,18 @@ export default Service.extend({
init: function() {
this._super(...arguments);
if (cache === null) {
this.resetCache();
}
this._listeners = this.dom.listeners();
},
resetCache: function() {
Object.entries(sources || {}).forEach(function([key, item]) {
item.close();
});
cache = new Map();
sources = new Map();
usage = new MultiMap(Set);
this._listeners = this.dom.listeners();
},
willDestroy: function() {
this._listeners.remove();

View File

@ -1,28 +1,46 @@
import Service from '@ember/service';
import Evented from '@ember/object/evented';
const buffer = {};
let buffers;
// TODO: This all should be replaced with {{#in-element}} if possible
export default Service.extend(Evented, {
init: function() {
this._super(...arguments);
buffers = {};
},
willDestroy: function() {
Object.entries(buffers).forEach(function([key, items]) {
items.forEach(function(item) {
item.remove();
});
});
buffers = null;
},
// TODO: Consider renaming this and/or
// `delete`ing the buffer (but not the DOM element)
// flush should flush, but maybe being able to re-flush
// after you've flushed could be handy
flush: function(name) {
return buffer[name];
return buffers[name];
},
add: function(name, dom) {
this.trigger('add', dom);
if (typeof buffer[name] === 'undefined') {
buffer[name] = [];
if (typeof buffers[name] === 'undefined') {
buffers[name] = [];
}
buffer[name].push(dom);
buffers[name].push(dom);
return dom;
},
remove: function(name) {
if (typeof buffer[name] !== 'undefined') {
buffer[name].forEach(function(item) {
remove: function(name, dom) {
if (typeof buffers[name] !== 'undefined') {
const buffer = buffers[name];
const i = buffer.findIndex(item => item === dom);
if (i !== -1) {
const item = buffer.splice(i, 1)[0];
item.remove();
});
delete buffer[name];
}
if (buffer.length === 0) {
delete buffers[name];
}
}
},
});

View File

@ -8,6 +8,7 @@
%form-element > span {
@extend %form-element-label;
}
%form button + em,
%form-element > em {
@extend %form-element-note;
}

View File

@ -38,6 +38,10 @@
min-height: 70px;
padding: 6px 13px;
}
/* TODO: notes after buttons need less space, ideally they'd be the same */
%form button + em {
margin-top: 0.5em;
}
%form-element-note {
margin-top: 2px;
}

View File

@ -1,9 +1,3 @@
%form-element-note {
font-style: normal;
}
%form-element-label {
font-weight: $typo-weight-semibold;
}
%form-element-text-input {
-moz-appearance: none;
-webkit-appearance: none;
@ -12,6 +6,8 @@
border: $decor-border-100;
outline: none;
}
%form fieldset > p,
%form-element-note,
%form-element-text-input::placeholder {
color: $gray-400;
}
@ -20,7 +16,7 @@
}
%form-element-error > input,
%form-element-error > textarea {
border-color: $color-failure !important;
border-color: var(--decor-error-500, $red-500) !important;
}
%form-element-text-input {
color: $gray-500;
@ -30,13 +26,10 @@
border-color: $gray-500;
}
%form-element-text-input-focus {
border-color: $blue-500;
border-color: var(--typo-action-500, $blue-500);
}
%form-element-label {
color: $black;
}
%form-element-note {
color: $gray-400;
color: var(--typo-contrast-999, inherit);
}
%form-element-note > code {
background-color: $gray-200;

File diff suppressed because one or more lines are too long

View File

@ -868,6 +868,16 @@
mask-image: $logo-alicloud-monochrome-svg;
}
%with-logo-auth0-color-icon {
@extend %with-icon;
background-image: $logo-auth0-color-svg;
}
%with-logo-auth0-color-mask {
@extend %with-mask;
-webkit-mask-image: $logo-auth0-color-svg;
mask-image: $logo-auth0-color-svg;
}
%with-logo-aws-color-icon {
@extend %with-icon;
background-image: $logo-aws-color-svg;
@ -1008,6 +1018,16 @@
mask-image: $logo-kubernetes-monochrome-svg;
}
%with-logo-okta-color-icon {
@extend %with-icon;
background-image: $logo-okta-color-svg;
}
%with-logo-okta-color-mask {
@extend %with-mask;
-webkit-mask-image: $logo-okta-color-svg;
mask-image: $logo-okta-color-svg;
}
%with-logo-oracle-color-icon {
@extend %with-icon;
background-image: $logo-oracle-color-svg;

View File

@ -12,7 +12,17 @@
main {
@extend %app-view;
}
%app-view-header form {
%app-view > div > header {
@extend %app-view-header;
}
%app-view > div > div {
@extend %app-view-content;
}
%app-view > div.unauthorized,
%app-view > div.error {
@extend %app-view-error;
}
%app-view header form {
@extend %filter-bar;
}
%app-view-actions a,
@ -23,9 +33,7 @@ main {
@extend %form-row;
}
[role='tabpanel'] > p:only-child,
.template-error > div,
%app-view-content > p:only-child,
%app-view.empty > div {
%app-view-content > p:only-child {
@extend %app-view-content-empty;
}
[role='tabpanel'] > *:first-child {

View File

@ -46,6 +46,9 @@
padding-bottom: 0.2em;
margin-bottom: 0.5em;
}
%app-view-error > div {
padding: 1px 0 30px 0;
}
%app-view-content-empty {
margin-top: 0;
padding: 50px;

View File

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

View File

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View File

@ -0,0 +1,34 @@
%auth-form {
width: 320px;
margin: 10px 25px;
}
%auth-form form {
margin-bottom: 0.5em !important;
}
%auth-form .ember-basic-dropdown-trigger,
%auth-form button {
width: 100%;
}
%auth-form .progress {
margin: 0 auto;
}
%auth-form > p:not(.error) {
@extend %auth-form-hr;
}
%auth-form-hr {
text-align: center;
position: relative;
}
%auth-form-hr span {
display: inline-block;
padding: 5px;
}
%auth-form-hr::before {
@extend %as-pseudo;
width: 100%;
position: absolute;
left: 0;
top: 50%;
z-index: -1;
}

View File

@ -0,0 +1,12 @@
%auth-form-hr {
text-transform: uppercase;
}
%auth-form-hr::before {
border-top: 1px solid $gray-200;
}
/* This is to mask off the hr so it has a space */
/* in the center so if the background color of what the */
/* line is on is different, then this should be different */
%auth-form-hr span {
background-color: $white;
}

View File

@ -0,0 +1,4 @@
@import './auth-modal/index';
#login-toggle + div {
@extend %auth-modal;
}

View File

@ -0,0 +1,7 @@
/*TODO: This is a different style of modal dialog */
/* and should probably be part of that, offering different styles*/
@import './skin';
@import './layout';
%auth-modal footer button {
@extend %anchor;
}

View File

@ -0,0 +1,12 @@
%auth-modal footer {
border: 0;
padding-top: 10px;
padding-bottom: 20px;
margin: 0px 26px;
}
%auth-modal footer {
background-color: $transparent;
}
%auth-modal > div > div > div {
padding-bottom: 0;
}

View File

@ -0,0 +1,7 @@
%auth-modal footer button::after {
@extend %with-chevron-right-mask, %as-pseudo;
font-size: 120%;
position: relative;
top: -1px;
left: -3px;
}

View File

@ -3,15 +3,17 @@ button[type='submit'],
a.type-create {
@extend %primary-button;
}
// the :not(li)'s here avoid styling action-group buttons
// TODO: Once we move action-groups to use aria menu we can get rid of
// some of this and just use not(aria-haspopup)
button[type='reset'],
:not(li) > button[type='button']:not(.copy-btn):not(.type-delete):not([aria-haspopup='menu']),
form button[type='button']:not([aria-haspopup='menu']),
header .actions button[type='button']:not(.copy-btn),
button.type-cancel,
html.template-error div > a {
@extend %secondary-button;
}
:not(li) > button.type-delete {
.with-confirmation .type-delete,
form button[type='button'].type-delete {
@extend %dangerous-button;
}
button.copy-btn {

View File

@ -31,6 +31,9 @@
%empty-state.status-403 header::before {
@extend %with-disabled-mask;
}
%empty-state[class*='status-5'] header::before {
@extend %with-alert-circle-outline-mask;
}
%empty-state .docs-link > *::before {
@extend %with-docs-mask, %as-pseudo;
}

View File

@ -11,33 +11,30 @@ label span {
.has-error {
@extend %form-element-error;
}
%main-content .type-password,
%main-content .type-text {
@extend %form-element;
}
.type-toggle {
@extend %form-element, %sliding-toggle;
}
%form-element,
.checkbox-group {
@extend %checkbox-group;
}
%main-content form {
@extend %form;
}
%form table,
%radio-group,
%checkbox-group,
form table,
%app-view-content form dl {
%main-content form dl {
@extend %form-row;
}
%radio-group label,
%main-content .type-password,
%main-content .type-text {
@extend %form-element;
}
%app-view-content form:not(.filter-bar) [role='radiogroup'],
%modal-window [role='radiogroup'] {
@extend %radio-group;
}
%radio-group label {
@extend %form-element;
}
.checkbox-group {
@extend %checkbox-group;
}
fieldset > p {
color: $gray-400;
}
%sliding-toggle + .checkbox-group {
margin-top: -1em;
}

View File

@ -23,8 +23,11 @@
@import './confirmation-dialog';
@import './feedback-dialog';
@import './modal-dialog';
@import './auth-form';
@import './auth-modal';
@import './notice';
@import './sort-control';
@import './oidc-select';
@import './discovery-chain';
@import './consul-intention-list';
@import './empty-state';

View File

@ -5,13 +5,20 @@
%main-nav-horizontal > ul > li > label > * {
@extend %main-nav-horizontal-action;
}
%main-nav-horizontal > ul > li > label {
display: block;
}
%main-nav-horizontal-action:not(span):hover,
%main-nav-horizontal-action:not(span):focus {
@extend %main-nav-horizontal-action-intent;
}
/* Whilst we want spans to look the same as actions */
/* we don't want them to act the same */
%main-nav-horizontal > ul > li > span {
cursor: default;
}
%main-nav-horizontal > ul > li > label {
display: block;
}
/* ^ why not in layout? because we want these */
/* overwrites to be close to where we extend? */
%main-nav-horizontal [type='checkbox']:checked + label > *,
%main-nav-horizontal > ul > li > a:active,
%main-nav-horizontal > ul > li.is-active > a,

View File

@ -18,9 +18,6 @@
border-radius: $decor-radius-200;
}
%main-nav-horizontal-action {
cursor: default;
}
%main-nav-horizontal-action:not(span) {
cursor: pointer;
}
%main-nav-horizontal-action,

View File

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

View File

@ -0,0 +1,8 @@
@import './skin';
@import './layout';
%oidc-select label {
@extend %form-element;
}
%oidc-select button {
@extend %secondary-button;
}

View File

@ -0,0 +1,7 @@
%oidc-select li,
%oidc-select .ember-power-select-trigger {
margin-bottom: 1em;
}
%oidc-select .ember-power-select-trigger {
width: 100%;
}

View File

@ -0,0 +1,30 @@
%oidc-select [class$='-oidc-provider']::before {
@extend %as-pseudo;
width: 22px;
height: 22px;
/* this is to prevent resizing in an inline-flex context */
/* and should probably be moved to the parent*/
flex: 0 0 auto;
margin-right: 10px;
}
%oidc-select .auth0-oidc-provider::before {
@extend %with-logo-auth0-color-icon;
}
%oidc-select .okta-oidc-provider::before {
@extend %with-logo-okta-color-icon;
}
%oidc-select .gitlab-oidc-provider::before {
@extend %with-logo-gitlab-color-icon;
}
%oidc-select .aws-oidc-provider::before {
@extend %with-logo-aws-color-icon;
}
%oidc-select .azure-oidc-provider::before {
@extend %with-logo-azure-color-icon;
}
%oidc-select .bitbucket-oidc-provider::before {
@extend %with-logo-bitbucket-color-icon;
}
%oidc-select .gcp-oidc-provider::before {
@extend %with-logo-gcp-color-icon;
}

View File

@ -77,6 +77,7 @@ pre code,
/*TODO: See if we can collapse these into a */
/* strong %p3 */
%form-element-label,
%button {
font-weight: $typo-weight-semibold;
}
@ -104,7 +105,7 @@ pre code,
font-weight: $typo-weight-normal;
}
%form-element > em,
%form-element-note,
%tbody-th em,
%app-view h1 em {
font-style: normal;

View File

@ -22,6 +22,11 @@
--brand-800: #{$magenta-800};
// --brand-900: #{$magenta-900};
/* themeable ui colors */
--typo-action-500: #{$blue-500};
--decor-error-500: #{$red-500};
--typo-contrast-999: #{$black};
/* themeable brand colors */
--typo-brand-050: var(--brand-050);
--typo-brand-600: var(--brand-600);

View File

@ -1,7 +1,15 @@
<HeadLayout />
{{title 'Consul' separator=' - '}}
{{#if (not-eq router.currentRouteName 'application')}}
<HashicorpConsul @id="wrapper" @permissions={{permissions}} @dcs={{dcs}} @dc={{or dc dcs.firstObject}} @nspaces={{nspaces}} @nspace={{or nspace nspaces.firstObject}}>
<HashicorpConsul
id="wrapper"
@permissions={{permissions}}
@dcs={{dcs}}
@dc={{or dc dcs.firstObject}}
@nspaces={{nspaces}}
@nspace={{or nspace nspaces.firstObject}}
@onchange={{action "reauthorize"}}
>
{{#if (not loading)}}
{{outlet}}
{{else}}

View File

@ -1,13 +1,21 @@
<p>
The Access Control List (ACL) system, which controls permissions for this UI, is enabled for this cluster. Your token ID will be saved locally and persist through visits.
</p>
<form>
<fieldset>
<label class="type-text">
<span>SecretID or Token</span>
<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}}>Login</button>
</form>
<EmptyState class="status-403">
<BlockSlot @name="header">
<h2>You are not authorized</h2>
</BlockSlot>
<BlockSlot @name="subheader">
<h3>Error 403</h3>
</BlockSlot>
<BlockSlot @name="body">
<p>
You must be granted permissions to view this data. Login or ask your administrator if you think you should have access.
</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

@ -1,19 +1,58 @@
{{#if error}}
<AppView @class="error show">
<BlockSlot @name="header">
<h1 data-test-error>
{{#if error.status }}
{{error.status}} ({{error.message}})
{{else}}
{{error.message}}
{{/if}}
</h1>
<h1>
Error {{error.status}}
</h1>
</BlockSlot>
<BlockSlot @name="content">
<p>
Consul returned an error.
You may have visited a URL that is loading an unknown resource, so you can try going back to the root or try re-submitting your ACL Token/SecretID by going back to ACLs.<br />
Try looking in our <a href="{{env 'CONSUL_DOCS_URL'}}" target="_blank">documentation</a>
</p>
<a data-test-home rel="home" href={{href-to 'index'}}>Go back to root</a>
{{#if (not-eq error.status "403")}}
<EmptyState class={{concat "status-" error.status}}>
<BlockSlot @name="header">
<h2>{{or error.message "Consul returned an error"}}</h2>
</BlockSlot>
{{#if error.status }}
<BlockSlot @name="subheader">
<h3 data-test-status={{error.status}}>Error {{error.status}}</h3>
</BlockSlot>
{{/if}}
<BlockSlot @name="body">
<p>
You may have visited a URL that is loading an unknown resource, so you can try going back to the root or try re-submitting your ACL Token/SecretID by going back to ACLs.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="back-link">
<a data-test-home rel="home" href={{href-to 'index'}}>Go back to root</a>
</li>
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
</BlockSlot>
</EmptyState>
{{else}}
<EmptyState class="status-403">
<BlockSlot @name="header">
<h2 data-test-status={{error.status}}>You are not authorized</h2>
</BlockSlot>
<BlockSlot @name="subheader">
<h3>Error 403</h3>
</BlockSlot>
<BlockSlot @name="body">
<p>
You must be granted permissions to view this data. Ask your administrator if you think you should have access.
</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>
{{/if}}
</BlockSlot>
</AppView>
{{/if}}

View File

@ -82,6 +82,9 @@ export default function(config = {}, win = window, doc = document) {
case 'CONSUL_NSPACES_ENABLE':
prev['CONSUL_NSPACES_ENABLED'] = !!JSON.parse(String(value).toLowerCase());
break;
case 'CONSUL_SSO_ENABLE':
prev['CONSUL_SSO_ENABLED'] = !!JSON.parse(String(value).toLowerCase());
break;
default:
prev[key] = value;
}

View File

@ -7,6 +7,10 @@ module.exports = function(environment, $ = process.env) {
environment,
rootURL: '/ui/',
locationType: 'auto',
// We use a complete dynamically (from Consul) configured
// torii provider. We provide this object here to
// prevent ember from giving a log message when starting ember up
torii: {},
EmberENV: {
FEATURES: {
// Here you can enable experimental features on an ember canary build
@ -69,6 +73,7 @@ module.exports = function(environment, $ = process.env) {
CONSUL_BINARY_TYPE: process.env.CONSUL_BINARY_TYPE ? process.env.CONSUL_BINARY_TYPE : 'oss',
CONSUL_ACLS_ENABLED: false,
CONSUL_NSPACES_ENABLED: false,
CONSUL_SSO_ENABLED: false,
CONSUL_HOME_URL: 'https://www.consul.io',
CONSUL_REPO_ISSUES_URL: 'https://github.com/hashicorp/consul/issues/new/choose',
@ -77,6 +82,7 @@ module.exports = function(environment, $ = process.env) {
CONSUL_DOCS_API_URL: 'https://www.consul.io/api',
CONSUL_COPYRIGHT_URL: 'https://www.hashicorp.com',
});
const isTestLike = ['staging', 'test'].indexOf(environment) > -1;
const isDevLike = ['development', 'staging', 'test'].indexOf(environment) > -1;
const isProdLike = ['production', 'staging'].indexOf(environment) > -1;
switch (true) {
@ -88,6 +94,10 @@ module.exports = function(environment, $ = process.env) {
typeof $['CONSUL_NSPACES_ENABLED'] !== 'undefined'
? !!JSON.parse(String($['CONSUL_NSPACES_ENABLED']).toLowerCase())
: true,
CONSUL_SSO_ENABLED:
typeof $['CONSUL_SSO_ENABLED'] !== 'undefined'
? !!JSON.parse(String($['CONSUL_SSO_ENABLED']).toLowerCase())
: false,
'@hashicorp/ember-cli-api-double': {
'auto-import': false,
enabled: true,
@ -107,6 +117,7 @@ module.exports = function(environment, $ = process.env) {
case environment === 'staging':
ENV = Object.assign({}, ENV, {
CONSUL_NSPACES_ENABLED: true,
CONSUL_SSO_ENABLED: true,
'@hashicorp/ember-cli-api-double': {
enabled: true,
endpoints: {
@ -118,13 +129,14 @@ module.exports = function(environment, $ = process.env) {
case environment === 'production':
ENV = Object.assign({}, ENV, {
CONSUL_ACLS_ENABLED: '{{.ACLsEnabled}}',
CONSUL_SSO_ENABLED: '{{.SSOEnabled}}',
CONSUL_NSPACES_ENABLED:
'{{ if .NamespacesEnabled }}{{.NamespacesEnabled}}{{ else }}false{{ end }}',
});
break;
}
switch (true) {
case isDevLike:
case isTestLike:
ENV = Object.assign({}, ENV, {
CONSUL_ACLS_ENABLED: true,
// 'APP': Object.assign({}, ENV.APP, {

View File

@ -17,6 +17,9 @@ const express = require('express');
module.exports = {
name: 'startup',
serverMiddleware: function(server) {
// TODO: see if we can move these into the project specific `/server` directory
// instead of inside an addon
// Serve the coverage folder for easy viewing during development
server.app.use('/coverage', express.static('coverage'));

View File

@ -25,7 +25,8 @@ module.exports = ({ appName, environment, rootURL, config }) => `
{
rootURL: '${rootURL}',
CONSUL_ACLS_ENABLED: ${config.CONSUL_ACLS_ENABLED},
CONSUL_NSPACES_ENABLED: ${config.CONSUL_NSPACES_ENABLED}
CONSUL_NSPACES_ENABLED: ${config.CONSUL_NSPACES_ENABLED},
CONSUL_SSO_ENABLED: ${config.CONSUL_SSO_ENABLED}
}
);
</script>

View File

@ -10,6 +10,7 @@ test(
environment: 'production',
CONSUL_BINARY_TYPE: 'oss',
CONSUL_ACLS_ENABLED: '{{.ACLsEnabled}}',
CONSUL_SSO_ENABLED: '{{.SSOEnabled}}',
CONSUL_NSPACES_ENABLED: '{{ if .NamespacesEnabled }}{{.NamespacesEnabled}}{{ else }}false{{ end }}',
},
{
@ -17,6 +18,7 @@ test(
CONSUL_BINARY_TYPE: 'oss',
CONSUL_ACLS_ENABLED: true,
CONSUL_NSPACES_ENABLED: true,
CONSUL_SSO_ENABLED: false,
},
{
$: {
@ -26,12 +28,24 @@ test(
CONSUL_BINARY_TYPE: 'oss',
CONSUL_ACLS_ENABLED: true,
CONSUL_NSPACES_ENABLED: false,
CONSUL_SSO_ENABLED: false,
},
{
$: {
CONSUL_SSO_ENABLED: 0
},
environment: 'test',
CONSUL_BINARY_TYPE: 'oss',
CONSUL_ACLS_ENABLED: true,
CONSUL_NSPACES_ENABLED: true,
CONSUL_SSO_ENABLED: false,
},
{
environment: 'staging',
CONSUL_BINARY_TYPE: 'oss',
CONSUL_ACLS_ENABLED: true,
CONSUL_NSPACES_ENABLED: true,
CONSUL_SSO_ENABLED: true,
}
].forEach(
function(item) {

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Consul</title>
<script>
var CURRENT_REQUEST_KEY = '__torii_request';
var pendingRequestKey = window.localStorage.getItem(CURRENT_REQUEST_KEY);
if (pendingRequestKey) {
window.localStorage.removeItem(CURRENT_REQUEST_KEY);
var url = window.location.toString();
window.localStorage.setItem(pendingRequestKey, url);
}
window.close();
</script>
</head>
</html>

26
ui-v2/server/index.js Normal file
View File

@ -0,0 +1,26 @@
'use strict';
const fs = require('fs');
const promisify = require('util').promisify;
const read = promisify(fs.readFile);
module.exports = function(app, options) {
// During development the proxy server has no way of
// knowing the content/mime type of our `oidc/callback` file
// as it has no extension.
// This shims the default server to set the correct headers
// just for this file
const file = `/oidc/callback`;
const rootURL = options.rootURL;
const url = `${rootURL.substr(0, rootURL.length - 1)}${file}`;
app.use(function(req, resp, next) {
if (req.url.split('?')[0] === url) {
return read(`${process.cwd()}/public${file}`).then(function(buffer) {
resp.header('Content-Type', 'text/html');
resp.write(buffer.toString());
resp.end();
});
}
next();
});
};

View File

@ -20,10 +20,10 @@ Feature: dc / acls / policies / as many / add existing: Add existing policy
[Model]: key
---
Then the url should be /datacenter/acls/[Model]s/key
And I click "#policies .ember-power-select-trigger"
And I click "form > #policies .ember-power-select-trigger"
And I click ".ember-power-select-option:first-child"
And I see 1 policy model on the policies component
And I click "#policies .ember-power-select-trigger"
And I click "form > #policies .ember-power-select-trigger"
And I click ".ember-power-select-option:nth-child(1)"
And I see 2 policy models on the policies component
And I submit

View File

@ -21,10 +21,10 @@ Feature: dc / acls / roles / as many / add existing: Add existing
token: key
---
Then the url should be /datacenter/acls/tokens/key
And I click "#roles .ember-power-select-trigger"
And I click "form > #roles .ember-power-select-trigger"
And I click ".ember-power-select-option:first-child"
And I see 1 role model on the roles component
And I click "#roles .ember-power-select-trigger"
And I click "form > #roles .ember-power-select-trigger"
And I click ".ember-power-select-option:nth-child(1)"
And I see 2 role models on the roles component
Then I fill in with yaml

View File

@ -22,7 +22,7 @@ Feature: dc / error: Recovering from a dc 500 error
---
Then the url should be /dc-500/services
And the title should be "Consul"
Then I see the text "500 (The backend responded with an error)" in "[data-test-error]"
Then I see status on the error like "500"
Scenario: Clicking the back to root button
Given the url "/v1/internal/ui/services" responds with a 200 status
When I click home

View File

@ -10,7 +10,7 @@ Feature: dc / services / error
---
dc: 404-datacenter
---
Then I see the text "404 (Page not found)" in "[data-test-error]"
Then I see status on the error like "404"
@notNamespaceable
Scenario: Arriving at the service page
Given 2 datacenter models from yaml
@ -23,7 +23,7 @@ Feature: dc / services / error
---
dc: dc-1
---
Then I see the text "500 (The backend responded with an error)" in "[data-test-error]"
Then I see status on the error like "500"
# This is the actual step that works slightly differently
# When running through namespaces as the dc menu says 'Error'
# which is still kind of ok

View File

@ -11,6 +11,6 @@ Feature: dc / services / instances / error: Visit Service Instance what doesn't
id: id-that-doesnt-exist
---
Then the url should be /dc1/services/service-0/instances/node-0/id-that-doesnt-exist
And I see the text "404 (Unable to find instance)" in "[data-test-error]"
Then I see status on the error like "404"

View File

@ -1,5 +1,5 @@
@setupApplicationTest
Feature: dc / acls / tokens / login-errors: ACL Login Errors
Feature: login-errors: Login Errors
Scenario: I get any 500 error that is not the specific legacy token cluster one
Given 1 datacenter model with the value "dc-1"
@ -9,7 +9,7 @@ Feature: dc / acls / tokens / login-errors: ACL Login Errors
dc: dc-1
---
Then the url should be /dc-1/acls/tokens
Then I see the text "500 (The backend responded with an error)" in "[data-test-error]"
Then I see status on the error like "500"
Scenario: I get a 500 error from acl/tokens that is the specific legacy one
Given 1 datacenter model with the value "dc-1"
And the url "/v1/acl/tokens?ns=@namespace" responds with from yaml
@ -43,11 +43,11 @@ Feature: dc / acls / tokens / login-errors: ACL Login Errors
---
Then the url should be /dc-1/acls/tokens
Then ".app-view" has the "unauthorized" class
Then I fill in with yaml
And I click login on the navigation
And I fill in the auth form with yaml
---
secret: something
SecretID: something
---
And I submit
Then ".app-view" has the "unauthorized" class
And "[data-notification]" has the "error" class
And I click submit on the authdialog.form
Then I see status on the error like "403"

View File

@ -1,6 +1,6 @@
@setupApplicationTest
Feature: dc / acls / tokens / login
Scenario: Logging into the ACLs login page
Feature: login
Scenario: Logging into the login page from ACLs tokens
Given 1 datacenter model with the value "dc-1"
And the url "/v1/acl/tokens" responds with a 403 status
When I visit the tokens page for yaml
@ -8,11 +8,12 @@ Feature: dc / acls / tokens / login
dc: dc-1
---
Then the url should be /dc-1/acls/tokens
And I fill in with yaml
And I click login on the navigation
And I fill in the auth form with yaml
---
secret: something
SecretID: something
---
And I submit
And I click submit on the authdialog.form
Then a GET request was made to "/v1/acl/token/self?dc=dc-1" from yaml
---
headers:

View File

@ -0,0 +1,10 @@
import steps from './steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from './steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -21,11 +21,12 @@ Feature: token-header
dc: datacenter
---
Then the url should be /datacenter/acls/tokens
Then I fill in with yaml
And I click login on the navigation
And I fill in the auth form with yaml
---
secret: [Token]
SecretID: [Token]
---
And I submit
And I click submit on the authdialog.form
When I visit the index page
Then the url should be /datacenter/services
And a GET request was made to "/v1/internal/ui/services?dc=datacenter&ns=@namespace" from yaml

View File

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | auth-dialog', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<AuthDialog />`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
<AuthDialog>
template block text
</AuthDialog>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -0,0 +1,24 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | auth-profile', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<AuthProfile />`);
assert.ok(this.element.textContent.indexOf('AccessorID') !== -1);
// Template block usage:
await render(hbs`
<AuthProfile></AuthProfile>
`);
assert.ok(this.element.textContent.indexOf('AccessorID') !== -1);
});
});

View File

@ -0,0 +1,17 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | jwt-source', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<JwtSource />`);
assert.equal(this.element.textContent.trim(), '');
});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | oidc-select', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<OidcSelect />`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
<OidcSelect>
template block text
</OidcSelect>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -0,0 +1,75 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { get } from 'consul-ui/tests/helpers/api';
import {
HEADERS_SYMBOL as META,
HEADERS_DATACENTER as DC,
HEADERS_NAMESPACE as NSPACE,
} from 'consul-ui/utils/http/consul';
module('Integration | Serializer | oidc-provider', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const undefinedNspace = 'default';
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
test(`respondForQuery returns the correct data for list endpoint when the nspace is ${nspace}`, function(assert) {
const serializer = this.owner.lookup('serializer:oidc-provider');
const request = {
url: `/v1/internal/ui/oidc-auth-methods?dc=${dc}`,
};
return get(request.url).then(function(payload) {
const expected = payload.map(item =>
Object.assign({}, item, {
Datacenter: dc,
Namespace: item.Namespace || undefinedNspace,
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`,
})
);
const actual = serializer.respondForQuery(
function(cb) {
const headers = {};
const body = payload;
return cb(headers, body);
},
{
dc: dc,
}
);
assert.deepEqual(actual, expected);
});
});
test(`respondForQueryRecord returns the correct data for item endpoint when the nspace is ${nspace}`, function(assert) {
const serializer = this.owner.lookup('serializer:oidc-provider');
const dc = 'dc-1';
const id = 'slug';
const request = {
url: `/v1/acl/oidc/auth-url?dc=${dc}`,
};
return get(request.url).then(function(payload) {
const expected = Object.assign({}, payload, {
Name: id,
Datacenter: dc,
[META]: {
[DC.toLowerCase()]: dc,
[NSPACE.toLowerCase()]: payload.Namespace || undefinedNspace,
},
Namespace: payload.Namespace || undefinedNspace,
uid: `["${payload.Namespace || undefinedNspace}","${dc}","${id}"]`,
});
const actual = serializer.respondForQueryRecord(
function(cb) {
const headers = {};
const body = payload;
return cb(headers, body);
},
{
dc: dc,
id: id,
}
);
assert.deepEqual(actual, expected);
});
});
});
});

View File

@ -1,5 +1,5 @@
import {
create,
create as createPage,
clickable,
is,
attribute,
@ -16,7 +16,6 @@ import createCancelable from 'consul-ui/tests/lib/page-object/createCancelable';
// TODO: All component-like page objects should be moved into the component folder
// along with all of its other dependencies once we can mae ember-cli ignore them
import page from 'consul-ui/tests/pages/components/page';
import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
import tabgroup from 'consul-ui/tests/lib/page-object/tabgroup';
import freetextFilter from 'consul-ui/tests/pages/components/freetext-filter';
@ -30,7 +29,9 @@ import policyFormFactory from 'consul-ui/tests/pages/components/policy-form';
import policySelectorFactory from 'consul-ui/tests/pages/components/policy-selector';
import roleFormFactory from 'consul-ui/tests/pages/components/role-form';
import roleSelectorFactory from 'consul-ui/tests/pages/components/role-selector';
import pageFactory from 'consul-ui/tests/pages/components/page';
import consulIntentionListFactory from 'consul-ui/tests/pages/components/consul-intention-list';
import authFormFactory from 'consul-ui/tests/pages/components/auth-form';
// TODO: should this specifically be modal or form?
// should all forms be forms?
@ -72,12 +73,21 @@ const roleForm = roleFormFactory(submitable, cancelable, policySelector);
const roleSelector = roleSelectorFactory(clickable, deletable, collection, alias, roleForm);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, deletable);
const authForm = authFormFactory(submitable, clickable, attribute);
const page = pageFactory(clickable, attribute, is, authForm);
const create = function(appView) {
appView = {
...page(),
...appView,
};
return createPage(appView);
};
export default {
index: create(index(visitable, collection)),
dcs: create(dcs(visitable, clickable, attribute, collection)),
services: create(
services(visitable, clickable, text, attribute, collection, page, popoverSort, radiogroup)
services(visitable, clickable, text, attribute, collection, popoverSort, radiogroup)
),
service: create(
service(visitable, attribute, collection, text, consulIntentionList, catalogToolbar, tabgroup)

View File

@ -0,0 +1,6 @@
export default (submitable, clickable, attribute) => (scope = '.auth-form') => {
return {
scope: scope,
...submitable(),
};
};

View File

@ -1,33 +1,50 @@
import { clickable, is } from 'ember-cli-page-object';
const page = {
navigation: ['services', 'nodes', 'kvs', 'acls', 'intentions', 'docs', 'settings'].reduce(
function(prev, item, i, arr) {
const key = item;
return Object.assign({}, prev, {
[key]: clickable(`[data-test-main-nav-${item}] a`),
});
export default (clickable, attribute, is, authForm) => scope => {
const page = {
navigation: [
'services',
'nodes',
'kvs',
'acls',
'intentions',
'help',
'settings',
'auth',
].reduce(
function(prev, item, i, arr) {
const key = item;
return Object.assign({}, prev, {
[key]: clickable(`[data-test-main-nav-${item}] > *`),
});
},
{
scope: '[data-test-navigation]',
}
),
footer: ['copyright', 'docs'].reduce(
function(prev, item, i, arr) {
const key = item;
return Object.assign({}, prev, {
[key]: clickable(`[data-test-main-nav-${item}`),
});
},
{
scope: '[data-test-footer]',
}
),
authdialog: {
form: authForm(),
},
{
scope: '[data-test-navigation]',
}
),
footer: ['copyright', 'docs'].reduce(
function(prev, item, i, arr) {
const key = item;
return Object.assign({}, prev, {
[key]: clickable(`[data-test-main-nav-${item}`),
});
error: {
status: attribute('data-test-status', '[data-test-status]'),
},
{
scope: '[data-test-footer]',
}
),
};
page.navigation.login = clickable('[data-test-main-nav-auth] label');
page.navigation.dc = clickable('[data-test-datacenter-menu] button');
page.navigation.nspace = clickable('[data-test-nspace-menu] button');
page.navigation.manageNspaces = clickable('[data-test-main-nav-nspaces] a');
page.navigation.manageNspacesIsVisible = is(
':checked',
'[data-test-nspace-menu] > input[type="checkbox"]'
);
return page;
};
page.navigation.dc = clickable('[data-test-datacenter-menu] button');
page.navigation.nspace = clickable('[data-test-nspace-menu] button');
page.navigation.manageNspaces = clickable('[data-test-main-nav-nspaces] a');
page.navigation.manageNspacesIsVisible = is(
':checked',
'[data-test-nspace-menu] > input[type="checkbox"]'
);
export default page;

View File

@ -6,6 +6,7 @@ export default function(visitable, submitable, deletable, cancelable, clickable)
use: clickable('[data-test-use]'),
confirmUse: clickable('button.type-delete'),
})
)
),
'main'
);
}

View File

@ -1,9 +1,9 @@
export default function(visitable, submitable, deletable, cancelable, clickable, tokenList) {
return {
visit: visitable(['/:dc/acls/policies/:policy', '/:dc/acls/policies/create']),
...submitable({}, 'form > div'),
...cancelable({}, 'form > div'),
...deletable({}, 'form > div'),
...submitable({}, 'main form > div'),
...cancelable({}, 'main form > div'),
...deletable({}, 'main form > div'),
tokens: tokenList(),
validDatacenters: clickable('[name="policy[isScoped]"]'),
datacenter: clickable('[name="policy[Datacenters]"]'),

View File

@ -1,9 +1,9 @@
export default function(visitable, submitable, deletable, cancelable, policySelector, tokenList) {
return {
visit: visitable(['/:dc/acls/roles/:role', '/:dc/acls/roles/create']),
...submitable({}, 'form > div'),
...cancelable({}, 'form > div'),
...deletable({}, 'form > div'),
...submitable({}, 'main form > div'),
...cancelable({}, 'main form > div'),
...deletable({}, 'main form > div'),
policies: policySelector(''),
tokens: tokenList(),
};

View File

@ -9,9 +9,9 @@ export default function(
) {
return {
visit: visitable(['/:dc/acls/tokens/:token', '/:dc/acls/tokens/create']),
...submitable({}, 'form > div'),
...cancelable({}, 'form > div'),
...deletable({}, 'form > div'),
...submitable({}, 'main form > div'),
...cancelable({}, 'main form > div'),
...deletable({}, 'main form > div'),
use: clickable('[data-test-use]'),
confirmUse: clickable('button.type-delete'),
clone: clickable('[data-test-clone]'),

View File

@ -4,6 +4,7 @@ export default function(visitable, submitable, deletable, cancelable) {
deletable({
visit: visitable(['/:dc/intentions/:intention', '/:dc/intentions/create']),
})
)
),
'main'
);
}

View File

@ -8,7 +8,7 @@ export default function(visitable, attribute, submitable, deletable, cancelable)
.map(encodeURIComponent)
.join('/');
}),
...submitable(),
...submitable({}, 'main'),
...cancelable(),
...deletable(),
session: {

View File

@ -8,9 +8,9 @@ export default function(
) {
return {
visit: visitable(['/:dc/namespaces/:namespace', '/:dc/namespaces/create']),
...submitable({}, 'form > div'),
...cancelable({}, 'form > div'),
...deletable({}, 'form > div'),
...submitable({}, 'main form > div'),
...cancelable({}, 'main form > div'),
...deletable({}, 'main form > div'),
policies: policySelector(),
roles: roleSelector(),
};

View File

@ -1,4 +1,4 @@
export default function(visitable, clickable, text, attribute, collection, page, popoverSort) {
export default function(visitable, clickable, text, attribute, collection, popoverSort) {
const service = {
name: text('[data-test-service-name]'),
service: clickable('a'),
@ -11,7 +11,6 @@ export default function(visitable, clickable, text, attribute, collection, page,
dcs: collection('[data-test-datacenter-picker]', {
name: clickable('a'),
}),
navigation: page.navigation,
home: clickable('[data-test-home]'),
sort: popoverSort,
};

View File

@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Model | oidc-provider', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('oidc-provider', {});
assert.ok(model);
});
});

View File

@ -0,0 +1,23 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | oidc-provider', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('oidc-provider');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('oidc-provider', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});