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:
parent
e4d27bc134
commit
412eec7f5d
|
@ -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`,
|
||||
}}
|
||||
`;
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
|
||||
---
|
|
@ -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: {},
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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' });
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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)
|
||||
|
||||
---
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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)
|
||||
|
||||
---
|
|
@ -0,0 +1,9 @@
|
|||
<dl>
|
||||
<dt>
|
||||
<span>My ACL Token</span><br />
|
||||
AccessorID
|
||||
</dt>
|
||||
<dd>
|
||||
{{substr item.AccessorID -8}}
|
||||
</dd>
|
||||
</dl>
|
|
@ -0,0 +1,5 @@
|
|||
import Component from '@ember/component';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: '',
|
||||
});
|
|
@ -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>
|
|
@ -8,6 +8,7 @@ import WithListeners from 'consul-ui/mixins/with-listeners';
|
|||
|
||||
export default Component.extend(SlotsMixin, WithListeners, {
|
||||
onchange: function() {},
|
||||
tagName: '',
|
||||
|
||||
error: function() {},
|
||||
type: '',
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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() {},
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
|
||||
---
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
export default {
|
||||
id: 'oidc-select',
|
||||
initial: 'loading',
|
||||
states: {
|
||||
loaded: {},
|
||||
loading: {
|
||||
on: {
|
||||
SUCCESS: [
|
||||
{
|
||||
target: 'loaded',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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'}} />
|
||||
|
|
|
@ -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)
|
||||
|
||||
---
|
|
@ -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: {},
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
{}
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
%form-element > span {
|
||||
@extend %form-element-label;
|
||||
}
|
||||
%form button + em,
|
||||
%form-element > em {
|
||||
@extend %form-element-note;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
@import './auth-form/index';
|
||||
.auth-form {
|
||||
@extend %auth-form;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
@import './skin';
|
||||
@import './layout';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
@import './auth-modal/index';
|
||||
#login-toggle + div {
|
||||
@extend %auth-modal;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
@import './oidc-select/index';
|
||||
.oidc-select {
|
||||
@extend %oidc-select;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
@import './skin';
|
||||
@import './layout';
|
||||
%oidc-select label {
|
||||
@extend %form-element;
|
||||
}
|
||||
%oidc-select button {
|
||||
@extend %secondary-button;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
%oidc-select li,
|
||||
%oidc-select .ember-power-select-trigger {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
%oidc-select .ember-power-select-trigger {
|
||||
width: 100%;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
@ -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:
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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(), '');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export default (submitable, clickable, attribute) => (scope = '.auth-form') => {
|
||||
return {
|
||||
scope: scope,
|
||||
...submitable(),
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -6,6 +6,7 @@ export default function(visitable, submitable, deletable, cancelable, clickable)
|
|||
use: clickable('[data-test-use]'),
|
||||
confirmUse: clickable('button.type-delete'),
|
||||
})
|
||||
)
|
||||
),
|
||||
'main'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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]"]'),
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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]'),
|
||||
|
|
|
@ -4,6 +4,7 @@ export default function(visitable, submitable, deletable, cancelable) {
|
|||
deletable({
|
||||
visit: visitable(['/:dc/intentions/:intention', '/:dc/intentions/create']),
|
||||
})
|
||||
)
|
||||
),
|
||||
'main'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ export default function(visitable, attribute, submitable, deletable, cancelable)
|
|||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
}),
|
||||
...submitable(),
|
||||
...submitable({}, 'main'),
|
||||
...cancelable(),
|
||||
...deletable(),
|
||||
session: {
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue