UI: New ACLs (#4789)

UI to accompany the new ACLs APIs
This commit is contained in:
John Cowen 2018-10-19 16:17:02 +01:00 committed by Jack Pearkes
parent 54cc0820b4
commit 7d89e519a2
301 changed files with 6548 additions and 684 deletions

View File

@ -19,12 +19,35 @@ You will need the following things properly installed on your computer.
## Running / Development
The source code comes with a small server that runs enough of the consul API
as a set of mocks/fixtures to be able to run the UI without having to run
consul.
* `make start-api` or `yarn start:api` (this starts a Consul API double running
on http://localhost:3000)
* `make start` or `yarn start` to start the ember app that connects to the
above API double
* Visit your app at [http://localhost:4200](http://localhost:4200).
* Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests).
To enable ACLs using the mock API, use Web Inspector to set a cookie as follows:
```
CONSUL_ACLS_ENABLE=1
```
This will enable the ACLs login page, to which you can login with any ACL
token/secret.
You can also use a number of other cookie key/values to set various things whilst
developing the UI, such as (but not limited to):
```
CONSUL_SERVICE_COUNT=1000
CONSUL_NODE_CODE=1000
// etc etc
```
See `./node_modules/@hashicorp/consul-api-double` for more details.
### Code Generators
@ -33,7 +56,7 @@ Make use of the many generators for code, try `ember help generate` for more det
### Running Tests
You do not need to run `make start-api`/`yarn run start:api` to run the tests
Please note: You do not need to run `make start-api`/`yarn run start:api` to run the tests, but the same mock consul API is used.
* `make test` or `yarn run test`
* `make test-view` or `yarn run test:view` to view the tests running in Chrome

View File

@ -48,7 +48,9 @@ export default Adapter.extend({
});
},
cleanQuery: function(_query) {
delete _query.id;
if (typeof _query.id !== 'undefined') {
delete _query.id;
}
const query = { ..._query };
delete _query[DATACENTER_QUERY_PARAM];
return query;

View File

@ -0,0 +1,73 @@
import Adapter, {
REQUEST_CREATE,
REQUEST_UPDATE,
DATACENTER_QUERY_PARAM as API_DATACENTER_KEY,
} from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/policy';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
import { PUT as HTTP_PUT } from 'consul-ui/utils/http/method';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
return this.appendURL('acl/policies', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
if (typeof query.id === 'undefined') {
throw new Error('You must specify an id');
}
return this.appendURL('acl/policy', [query.id], this.cleanQuery(query));
},
urlForCreateRecord: function(modelName, snapshot) {
return this.appendURL('acl/policy', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForUpdateRecord: function(id, modelName, snapshot) {
return this.appendURL('acl/policy', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForDeleteRecord: function(id, modelName, snapshot) {
return this.appendURL('acl/policy', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForTranslateRecord: function(modelName, snapshot) {
return this.appendURL('acl/policy/translate', [], {});
},
dataForRequest: function(params) {
const data = this._super(...arguments);
switch (params.requestType) {
case REQUEST_UPDATE:
case REQUEST_CREATE:
return data.policy;
}
return data;
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case response === true:
response = this.handleBooleanResponse(url, response, PRIMARY_KEY, SLUG_KEY);
break;
case Array.isArray(response):
response = this.handleBatchResponse(url, response, PRIMARY_KEY, SLUG_KEY);
break;
default:
response = this.handleSingleResponse(url, response, PRIMARY_KEY, SLUG_KEY);
}
}
return this._super(status, headers, response, requestData);
},
methodForRequest: function(params) {
switch (params.requestType) {
case REQUEST_CREATE:
return HTTP_PUT;
}
return this._super(...arguments);
},
});

199
ui-v2/app/adapters/token.js Normal file
View File

@ -0,0 +1,199 @@
import { inject as service } from '@ember/service';
import Adapter, {
REQUEST_CREATE,
REQUEST_UPDATE,
DATACENTER_QUERY_PARAM as API_DATACENTER_KEY,
} from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/token';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
import { PUT as HTTP_PUT } from 'consul-ui/utils/http/method';
import { get } from '@ember/object';
const REQUEST_CLONE = 'cloneRecord';
const REQUEST_SELF = 'querySelf';
export default Adapter.extend({
store: service('store'),
cleanQuery: function(_query) {
const query = this._super(...arguments);
// TODO: Make sure policy is being passed through
delete _query.policy;
// take off the secret for /self
delete query.secret;
return query;
},
urlForQuery: function(query, modelName) {
return this.appendURL('acl/tokens', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
if (typeof query.id === 'undefined') {
throw new Error('You must specify an id');
}
return this.appendURL('acl/token', [query.id], this.cleanQuery(query));
},
urlForQuerySelf: function(query, modelName) {
return this.appendURL('acl/token/self', [], this.cleanQuery(query));
},
urlForCreateRecord: function(modelName, snapshot) {
return this.appendURL('acl/token', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForUpdateRecord: function(id, modelName, snapshot) {
// If a token has Rules, use the old API
if (typeof snapshot.attr('Rules') !== 'undefined') {
return this.appendURL('acl/update', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
}
return this.appendURL('acl/token', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForDeleteRecord: function(id, modelName, snapshot) {
return this.appendURL('acl/token', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForRequest: function({ type, snapshot, requestType }) {
switch (requestType) {
case 'cloneRecord':
return this.urlForCloneRecord(type.modelName, snapshot);
case 'querySelf':
return this.urlForQuerySelf(snapshot, type.modelName);
}
return this._super(...arguments);
},
urlForCloneRecord: function(modelName, snapshot) {
return this.appendURL('acl/token', [snapshot.attr(SLUG_KEY), 'clone'], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
self: function(store, modelClass, snapshot) {
const params = {
store: store,
type: modelClass,
snapshot: snapshot,
requestType: 'querySelf',
};
// _requestFor is private... but these methods aren't, until they disappear..
const request = {
method: this.methodForRequest(params),
url: this.urlForRequest(params),
headers: this.headersForRequest(params),
data: this.dataForRequest(params),
};
// TODO: private..
return this._makeRequest(request);
},
clone: function(store, modelClass, id, snapshot) {
const params = {
store: store,
type: modelClass,
id: id,
snapshot: snapshot,
requestType: 'cloneRecord',
};
// _requestFor is private... but these methods aren't, until they disappear..
const request = {
method: this.methodForRequest(params),
url: this.urlForRequest(params),
headers: this.headersForRequest(params),
data: this.dataForRequest(params),
};
// TODO: private..
return this._makeRequest(request);
},
handleSingleResponse: function(url, response, primary, slug) {
// Sometimes we get `Policies: null`, make null equal an empty array
if (typeof response.Policies === 'undefined' || response.Policies === null) {
response.Policies = [];
}
// Convert an old style update response to a new style
if (typeof response['ID'] !== 'undefined') {
const item = get(this, 'store')
.peekAll('token')
.findBy('SecretID', response['ID']);
if (item) {
response['SecretID'] = response['ID'];
response['AccessorID'] = get(item, 'AccessorID');
}
}
return this._super(url, response, primary, slug);
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case response === true:
response = this.handleBooleanResponse(url, response, PRIMARY_KEY, SLUG_KEY);
break;
case Array.isArray(response):
response = this.handleBatchResponse(url, response, PRIMARY_KEY, SLUG_KEY);
break;
default:
response = this.handleSingleResponse(url, response, PRIMARY_KEY, SLUG_KEY);
}
}
return this._super(status, headers, response, requestData);
},
methodForRequest: function(params) {
switch (params.requestType) {
case REQUEST_CLONE:
case REQUEST_CREATE:
return HTTP_PUT;
}
return this._super(...arguments);
},
headersForRequest: function(params) {
switch (params.requestType) {
case REQUEST_SELF:
return {
'X-Consul-Token': params.snapshot.secret,
};
}
return this._super(...arguments);
},
dataForRequest: function(params) {
let data = this._super(...arguments);
switch (params.requestType) {
case REQUEST_UPDATE:
// If a token has Rules, use the old API
if (typeof data.token['Rules'] !== 'undefined') {
data.token['ID'] = data.token['SecretID'];
data.token['Name'] = data.token['Description'];
}
// falls through
case REQUEST_CREATE:
if (Array.isArray(data.token.Policies)) {
data.token.Policies = data.token.Policies.filter(function(item) {
// Just incase, don't save any policies that aren't saved
return !get(item, 'isNew');
}).map(function(item) {
return {
ID: get(item, 'ID'),
Name: get(item, 'Name'),
};
});
} else {
delete data.token.Policies;
}
data = data.token;
break;
case REQUEST_SELF:
return {};
case REQUEST_CLONE:
data = {};
break;
}
// make sure we never send the SecretID
if (data && typeof data['SecretID'] !== 'undefined') {
delete data['SecretID'];
}
return data;
},
});

View File

@ -1,14 +1,16 @@
import Component from '@ember/component';
import SlotsMixin from 'ember-block-slots';
import { get } from '@ember/object';
import templatize from 'consul-ui/utils/templatize';
const $html = document.documentElement;
const templatize = function(arr = []) {
return arr.map(item => `template-${item}`);
};
export default Component.extend(SlotsMixin, {
loading: false,
authorized: true,
enabled: true,
classNames: ['app-view'],
classNameBindings: ['enabled::disabled', 'authorized::unauthorized'],
didReceiveAttrs: function() {
// right now only manually added classes are hoisted to <html>
let cls = get(this, 'class') || '';
if (get(this, 'loading')) {
cls += ' loading';

View File

@ -1,6 +1,12 @@
import Component from '@ember/component';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
const $$ = qsaFactory();
export default Component.extend({
mode: 'application/json',
classNames: ['code-editor'],
onkeyup: function() {},
didAppear: function() {
const $editor = [...$$('textarea + div', this.element)][0];
$editor.CodeMirror.refresh();
},
});

View File

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

View File

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

View File

@ -0,0 +1,19 @@
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import Component from '@ember/component';
const append = function(content) {
this.element.appendChild(content);
};
export default Component.extend({
buffer: service('dom-buffer'),
init: function() {
this._super(...arguments);
this.append = append.bind(this);
},
didInsertElement: function() {
get(this, 'buffer').on('add', this.append);
},
didDestroyElement: function() {
get(this, 'buffer').off('add', this.append);
},
});

View File

@ -0,0 +1,17 @@
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import Component from '@ember/component';
export default Component.extend({
buffer: service('dom-buffer'),
getBufferName: function() {
// TODO: Right now we are only using this for the modal layer
// moving forwards you'll be able to name your buffers
return 'modal';
},
didInsertElement: function() {
get(this, 'buffer').add(this.getBufferName(), this.element);
},
didDestroyElement: function() {
get(this, 'buffer').remove(this.getBufferName());
},
});

View File

@ -1,7 +1,7 @@
import Component from '@ember/component';
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
import qsaFactory from 'consul-ui/utils/qsa-factory';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
const $$ = qsaFactory();
import SlotsMixin from 'ember-block-slots';

View File

@ -3,7 +3,7 @@ import Component from 'ember-collection/components/ember-collection';
import PercentageColumns from 'ember-collection/layouts/percentage-columns';
import style from 'ember-computed-style';
import WithResizing from 'consul-ui/mixins/with-resizing';
import qsaFactory from 'consul-ui/utils/qsa-factory';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
const $$ = qsaFactory();
export default Component.extend(WithResizing, {
tagName: 'div',

View File

@ -0,0 +1,119 @@
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from 'consul-ui/components/dom-buffer';
import SlotsMixin from 'ember-block-slots';
import WithResizing from 'consul-ui/mixins/with-resizing';
import templatize from 'consul-ui/utils/templatize';
export default Component.extend(SlotsMixin, WithResizing, {
dom: service('dom'),
checked: true,
height: null,
// dialog is a reference to the modal-dialog 'panel' so its 'window'
dialog: null,
overflowingClass: 'overflowing',
onclose: function() {},
onopen: function() {},
_open: function(e) {
set(this, 'checked', true);
if (get(this, 'height') === null) {
if (this.element) {
const dialogPanel = get(this, 'dom').element('[role="dialog"] > div > div', this.element);
const rect = dialogPanel.getBoundingClientRect();
set(this, 'dialog', dialogPanel);
set(this, 'height', rect.height);
}
}
this.didAppear();
this.onopen(e);
},
didAppear: function() {
this._super(...arguments);
if (get(this, 'checked')) {
get(this, 'dom')
.root()
.classList.add(...templatize(['with-modal']));
}
},
_close: function(e) {
set(this, 'checked', false);
const dialogPanel = get(this, 'dialog');
const overflowing = get(this, 'overflowingClass');
if (dialogPanel.classList.contains(overflowing)) {
dialogPanel.classList.remove(overflowing);
}
// TODO: should we make a didDisappear?
get(this, 'dom')
.root()
.classList.remove(...templatize(['with-modal']));
this.onclose(e);
},
didReceiveAttrs: function() {
this._super(...arguments);
// TODO: Why does setting name mean checked it false?
// It's because if it has a name then it is likely to be linked
// to HTML state rather than just being added via HTMLBars
// and therefore likely to be immediately on the page
// It's not our usecase just yet, but this should check the state
// of the thing its linked to, incase that has a `checked` of true
// right now we know ours is always false.
if (get(this, 'name')) {
set(this, 'checked', false);
}
if (this.element) {
if (get(this, 'checked')) {
// TODO: probably need an event here
// possibly this.element for the target
// or find the input
this._open({ target: {} });
}
}
},
didInsertElement: function() {
this._super(...arguments);
if (get(this, 'checked')) {
// TODO: probably need an event here
// possibly this.element for the target
// or find the input
this._open({ target: {} });
}
},
didDestroyElement: function() {
this._super(...arguments);
get(this, 'dom')
.root()
.classList.remove(...templatize(['with-modal']));
},
resize: function(e) {
if (get(this, 'checked')) {
const height = get(this, 'height');
if (height !== null) {
const dialogPanel = get(this, 'dialog');
const overflowing = get(this, 'overflowingClass');
if (height > e.detail.height) {
if (!dialogPanel.classList.contains(overflowing)) {
dialogPanel.classList.add(overflowing);
}
return;
} else {
if (dialogPanel.classList.contains(overflowing)) {
dialogPanel.classList.remove(overflowing);
}
}
}
}
},
actions: {
change: function(e) {
if (e && e.target && e.target.checked) {
this._open(e);
} else {
this._close();
}
},
close: function() {
get(this, 'dom').element('#modal_close').checked = true;
this.onclose();
},
},
});

View File

@ -0,0 +1,18 @@
import Component from 'consul-ui/components/dom-buffer-flush';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
export default Component.extend({
dom: service('dom'),
actions: {
change: function(e) {
[...get(this, 'dom').elements('[name="modal"]')]
.filter(function(item) {
return item.getAttribute('id') !== 'modal_close';
})
.forEach(function(item) {
item.onchange();
});
},
},
});

View File

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

View File

@ -5,7 +5,11 @@ import Grid from 'ember-collection/layouts/grid';
import SlotsMixin from 'ember-block-slots';
import WithResizing from 'consul-ui/mixins/with-resizing';
import style from 'ember-computed-style';
import qsaFactory from 'consul-ui/utils/qsa-factory';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
import sibling from 'consul-ui/utils/dom/sibling';
import closest from 'consul-ui/utils/dom/closest';
import clickFirstAnchorFactory from 'consul-ui/utils/dom/click-first-anchor';
const clickFirstAnchor = clickFirstAnchorFactory(closest);
import { computed, get, set } from '@ember/object';
/**
@ -53,26 +57,6 @@ class ZIndexedGrid extends Grid {
return style;
}
}
// basic DOM closest utility to cope with no support
// TODO: instead of degrading gracefully
// add a while polyfill for closest
const closest = function(sel, el) {
try {
return el.closest(sel);
} catch (e) {
return;
}
};
const sibling = function(el, name) {
let sibling = el;
while ((sibling = sibling.nextSibling)) {
if (sibling.nodeType === 1) {
if (sibling.nodeName.toLowerCase() === name) {
return sibling;
}
}
}
};
/**
* The tabular-collection can contain 'actions' the UI for which
* uses dropdown 'action groups', so a group of different actions.
@ -131,11 +115,13 @@ const change = function(e) {
};
export default Component.extend(SlotsMixin, WithResizing, {
tagName: 'table',
classNames: ['dom-recycling'],
attributeBindings: ['style'],
width: 1150,
height: 500,
style: style('getStyle'),
checked: null,
hasCaption: false,
init: function() {
this._super(...arguments);
this.change = change.bind(this);
@ -149,12 +135,13 @@ export default Component.extend(SlotsMixin, WithResizing, {
};
}),
resize: function(e) {
const $tbody = [...$$('tbody', this.element)][0];
const $tbody = this.element;
const $appContent = [...$$('main > div')][0];
if ($appContent) {
const border = 1;
const rect = $tbody.getBoundingClientRect();
const $footer = [...$$('footer[role="contentinfo"]')][0];
const space = rect.top + $footer.clientHeight;
const space = rect.top + $footer.clientHeight + border;
const height = e.detail.height - space;
this.set('height', Math.max(0, height));
// TODO: The row height should auto calculate properly from the CSS
@ -165,7 +152,8 @@ export default Component.extend(SlotsMixin, WithResizing, {
},
willRender: function() {
this._super(...arguments);
this.set('hasActions', this._isRegistered('actions'));
set(this, 'hasCaption', this._isRegistered('caption'));
set(this, 'hasActions', this._isRegistered('actions'));
},
// `ember-collection` bug workaround
// https://github.com/emberjs/ember-collection/issues/138
@ -285,26 +273,7 @@ export default Component.extend(SlotsMixin, WithResizing, {
},
actions: {
click: function(e) {
// click on row functionality
// so if you click the actual row but not a link
// find the first link and fire that instead
const name = e.target.nodeName.toLowerCase();
switch (name) {
case 'input':
case 'label':
case 'a':
case 'button':
return;
}
const $a = closest('tr', e.target).querySelector('a');
if ($a) {
const click = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
$a.dispatchEvent(click);
}
return clickFirstAnchor(e);
},
},
});

View File

@ -0,0 +1,17 @@
import Component from '@ember/component';
import SlotsMixin from 'ember-block-slots';
import closest from 'consul-ui/utils/dom/closest';
import clickFirstAnchorFactory from 'consul-ui/utils/dom/click-first-anchor';
const clickFirstAnchor = clickFirstAnchorFactory(closest);
export default Component.extend(SlotsMixin, {
onchange: function() {},
actions: {
click: function(e) {
clickFirstAnchor(e);
},
change: function(item, e) {
this.onchange(e, item);
},
},
});

View File

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

View File

@ -0,0 +1,2 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -0,0 +1,45 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
export default Controller.extend({
builder: service('form'),
dom: service('dom'),
isScoped: false,
init: function() {
this._super(...arguments);
this.form = get(this, 'builder').form('policy');
},
setProperties: function(model) {
// essentially this replaces the data with changesets
this._super(
Object.keys(model).reduce((prev, key, i) => {
switch (key) {
case 'item':
prev[key] = this.form.setData(prev[key]).getData();
break;
}
return prev;
}, model)
);
set(this, 'isScoped', get(model.item, 'Datacenters.length') > 0);
},
actions: {
change: function(e, value, item) {
const form = get(this, 'form');
const event = get(this, 'dom').normalizeEvent(e, value);
try {
form.handleEvent(event);
} catch (err) {
const target = event.target;
switch (target.name) {
case 'policy[isScoped]':
set(this, 'isScoped', !get(this, 'isScoped'));
set(this.item, 'Datacenters', null);
break;
default:
throw err;
}
}
},
},
});

View File

@ -0,0 +1,23 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
export default Controller.extend(WithFiltering, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
filter: function(item, { s = '', type = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1
);
},
actions: {},
});

View File

@ -0,0 +1,2 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -0,0 +1,79 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
export default Controller.extend({
dom: service('dom'),
builder: service('form'),
isScoped: false,
init: function() {
this._super(...arguments);
this.form = get(this, 'builder').form('token');
},
setProperties: function(model) {
// essentially this replaces the data with changesets
this._super(
Object.keys(model).reduce((prev, key, i) => {
switch (key) {
case 'item':
prev[key] = this.form.setData(prev[key]).getData();
break;
case 'policy':
prev[key] = this.form
.form(key)
.setData(prev[key])
.getData();
break;
}
return prev;
}, model)
);
},
actions: {
sendClearPolicy: function(item) {
set(this, 'isScoped', false);
this.send('clearPolicy');
},
sendCreatePolicy: function(item, policies, success) {
this.send('createPolicy', item, policies, success);
},
refreshCodeEditor: function(selector, parent) {
if (parent.target) {
parent = undefined;
}
get(this, 'dom')
.component(selector, parent)
.didAppear();
},
change: function(e, value, item) {
const form = get(this, 'form');
const event = get(this, 'dom').normalizeEvent(e, value);
try {
form.handleEvent(event);
} catch (err) {
const target = event.target;
switch (target.name) {
case 'policy[isScoped]':
set(this, 'isScoped', !get(this, 'isScoped'));
set(this.policy, 'Datacenters', null);
break;
case 'Policy':
set(value, 'CreateTime', new Date().getTime());
get(this, 'item.Policies').pushObject(value);
break;
case 'Details':
// the Details expander toggle
// only load on opening
if (target.checked) {
this.send('refreshCodeEditor', '.code-editor', target.parentNode);
if (!get(value, 'Rules')) {
this.send('loadPolicy', value, get(this, 'item.Policies'));
}
}
break;
default:
throw err;
}
}
},
},
});

View File

@ -0,0 +1,33 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
export default Controller.extend(WithFiltering, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
filter: function(item, { s = '', type = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'AccessorID')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1 ||
(get(item, 'Policies') || []).some(function(item) {
return item.Name.toLowerCase().indexOf(sLower) !== -1;
})
);
},
actions: {
sendClone: function(item) {
this.send('clone', item);
},
},
});

View File

@ -2,7 +2,7 @@ import Controller from '@ember/controller';
import { get, set } from '@ember/object';
import { getOwner } from '@ember/application';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import qsaFactory from 'consul-ui/utils/qsa-factory';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
import getComponentFactory from 'consul-ui/utils/get-component-factory';
const $$ = qsaFactory();

10
ui-v2/app/forms/policy.js Normal file
View File

@ -0,0 +1,10 @@
import validations from 'consul-ui/validations/policy';
import builderFactory from 'consul-ui/utils/form/builder';
const builder = builderFactory();
export default function(name = 'policy', v = validations, form = builder) {
return form(name, {
Datacenters: {
type: 'array',
},
}).setValidators(v);
}

9
ui-v2/app/forms/token.js Normal file
View File

@ -0,0 +1,9 @@
import builderFactory from 'consul-ui/utils/form/builder';
import validations from 'consul-ui/validations/token';
import policy from 'consul-ui/forms/policy';
const builder = builderFactory();
export default function(name = '', v = validations, form = builder) {
return form(name, {})
.setValidators(v)
.add(policy());
}

View File

@ -0,0 +1,9 @@
import { helper } from '@ember/component/helper';
import { get } from '@ember/object';
export function difference(params, hash) {
return params[0].filter(function(item) {
return !params[1].findBy('ID', get(item, 'ID'));
});
}
export default helper(difference);

View File

@ -0,0 +1,16 @@
import { helper } from '@ember/component/helper';
import { get } from '@ember/object';
/**
* Datacenters can be an array of datacenters.
* Anything that isn't an array means 'All', even an empty array.
*/
export function datacenters(params, hash = {}) {
const datacenters = get(params[0], 'Datacenters');
if (!Array.isArray(datacenters) || datacenters.length === 0) {
return [hash['global'] || 'All'];
}
return get(params[0], 'Datacenters');
}
export default helper(datacenters);

View File

@ -0,0 +1,8 @@
import { helper } from '@ember/component/helper';
import { get } from '@ember/object';
const MANAGEMENT_ID = '00000000-0000-0000-0000-000000000001';
export function isManagement(params, hash) {
return get(params[0], 'ID') === MANAGEMENT_ID;
}
export default helper(isManagement);

View File

@ -0,0 +1,8 @@
import { helper } from '@ember/component/helper';
import { get } from '@ember/object';
const ANONYMOUS_ID = '00000000-0000-0000-0000-000000000002';
export function isAnonymous(params, hash) {
return get(params[0], 'AccessorID') === ANONYMOUS_ID;
}
export default helper(isAnonymous);

View File

@ -0,0 +1,9 @@
import { helper } from '@ember/component/helper';
import { get } from '@ember/object';
export function isLegacy(params, hash) {
const token = params[0];
return get(token, 'Legacy') || typeof get(token, 'Rules') !== 'undefined';
}
export default helper(isLegacy);

View File

@ -0,0 +1,19 @@
import token from 'consul-ui/forms/token';
import policy from 'consul-ui/forms/policy';
export function initialize(application) {
// Service-less injection using private properties at a per-project level
const FormBuilder = application.resolveRegistration('service:form');
const forms = {
token: token(),
policy: policy(),
};
FormBuilder.reopen({
form: function(name) {
return forms[name];
},
});
}
export default {
initialize,
};

View File

@ -0,0 +1,11 @@
export function initialize(application) {
const IvyCodeMirrorComponent = application.resolveRegistration('component:ivy-codemirror');
// Make sure ivy-codemirror respects/maintains a `name=""` attribute
IvyCodeMirrorComponent.reopen({
attributeBindings: ['name'],
});
}
export default {
initialize,
};

View File

@ -0,0 +1,24 @@
import Mixin from '@ember/object/mixin';
import { get } from '@ember/object';
/**
* Used for create-type Routes
*
* 'repo' is standardized across the app
* 'item' is standardized across the app
* they could be replaced with `getRepo` and `getItem`
*/
export default Mixin.create({
beforeModel: function() {
get(this, 'repo').invalidate();
},
deactivate: function() {
// TODO: This is dependent on ember-changeset
// Change changeset to support ember-data props
const item = get(this.controller, 'item.data');
// TODO: Look and see if rollbackAttributes is good here
if (get(item, 'isNew')) {
item.destroyRecord();
}
},
});

View File

@ -0,0 +1,4 @@
import Mixin from '@ember/object/mixin';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
export default Mixin.create(WithBlockingActions, {});

View File

@ -0,0 +1,61 @@
import Mixin from '@ember/object/mixin';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
export default Mixin.create(WithBlockingActions, {
settings: service('settings'),
actions: {
use: function(item) {
return get(this, 'feedback').execute(() => {
return get(this, 'repo')
.findBySlug(get(item, 'AccessorID'), this.modelFor('dc').dc.Name)
.then(item => {
return get(this, 'settings')
.persist({
token: {
AccessorID: get(item, 'AccessorID'),
SecretID: get(item, 'SecretID'),
},
})
.then(() => {
// using is similar to delete in that
// if you use from the listing page, stay on the listing page
// whereas if you use from the detail page, take me back to the listing page
return this.afterDelete(...arguments);
});
});
}, 'use');
},
logout: function(item) {
return get(this, 'feedback').execute(() => {
return get(this, 'settings')
.delete('token')
.then(() => {
// logging out is similar to delete in that
// if you log out from the listing page, stay on the listing page
// whereas if you logout from the detail page, take me back to the listing page
return this.afterDelete(...arguments);
});
}, 'logout');
},
clone: function(item) {
let cloned;
return get(this, 'feedback').execute(() => {
return get(this, 'repo')
.clone(item)
.then(item => {
cloned = item;
// cloning is similar to delete in that
// if you clone from the listing page, stay on the listing page
// whereas if you clone from another token, take me back to the listing page
// so I can see it
return this.afterDelete(...arguments);
})
.then(function() {
return cloned;
});
}, 'clone');
},
},
});

View File

@ -83,7 +83,7 @@ export default Mixin.create({
}
);
},
update: function(item, parent) {
update: function(item) {
return get(this, 'feedback').execute(
() => {
return get(this, 'repo')
@ -98,7 +98,7 @@ export default Mixin.create({
}
);
},
delete: function(item, parent) {
delete: function(item) {
return get(this, 'feedback').execute(
() => {
return get(this, 'repo')

View File

@ -0,0 +1,29 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import writable from 'consul-ui/utils/model/writable';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
const model = Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Name: attr('string', {
defaultValue: '',
}),
Description: attr('string', {
defaultValue: '',
}),
Rules: attr('string', {
defaultValue: '',
}),
// frontend only for ordering where CreateIndex can't be used
CreateTime: attr('date'),
//
Datacenter: attr('string'),
Datacenters: attr(),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
});
export const ATTRS = writable(model, ['Name', 'Description', 'Rules', 'Datacenters']);
export default model;

View File

@ -21,6 +21,7 @@ export default Model.extend({
EnableTagOverride: attr('boolean'),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
// TODO: These should be typed
ChecksPassing: attr(),
ChecksCritical: attr(),
ChecksWarning: attr(),

47
ui-v2/app/models/token.js Normal file
View File

@ -0,0 +1,47 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import writable from 'consul-ui/utils/model/writable';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'AccessorID';
const model = Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
SecretID: attr('string'),
// Legacy
Type: attr('string'),
Name: attr('string', {
defaultValue: '',
}),
Rules: attr('string'),
// End Legacy
Legacy: attr('boolean'),
Description: attr('string', {
defaultValue: '',
}),
Datacenter: attr('string'),
Local: attr('boolean'),
Policies: attr({
defaultValue: function() {
return [];
},
}),
CreateTime: attr('date'),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
});
// Name and Rules is only for legacy tokens
export const ATTRS = writable(model, [
'Name',
'Rules',
'Type',
'Local',
'Description',
'Policies',
// SecretID isn't writable but we need it to identify an
// update via the old API, see TokenAdapter dataForRequest
'SecretID',
'AccessorID',
]);
export default model;

View File

@ -35,6 +35,14 @@ Router.map(function() {
this.route('acls', { path: '/acls' }, function() {
this.route('edit', { path: '/:id' });
this.route('create', { path: '/create' });
this.route('policies', { path: '/policies' }, function() {
this.route('edit', { path: '/:id' });
this.route('create', { path: '/create' });
});
this.route('tokens', { path: '/tokens' }, function() {
this.route('edit', { path: '/:id' });
this.route('create', { path: '/create' });
});
});
});
@ -43,7 +51,7 @@ Router.map(function() {
this.route('index', { path: '/' });
// The settings page is global.
this.route('settings', { path: '/settings' });
// this.route('settings', { path: '/settings' });
this.route('notfound', { path: '/*path' });
});

View File

@ -38,6 +38,7 @@ export default Route.extend({
return true;
},
error: function(e, transition) {
// TODO: Normalize all this better
let error = {
status: e.code || '',
message: e.message || e.detail || 'Error',
@ -46,6 +47,20 @@ export default Route.extend({
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
if (error.status === '403') {
return this.transitionTo('dc.acls.tokens');
}
if (error.status === '') {
error.message = 'Error';
}

View File

@ -0,0 +1,31 @@
import Route from '@ember/routing/route';
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
export default Route.extend(WithBlockingActions, {
settings: service('settings'),
feedback: service('feedback'),
repo: service('tokens'),
actions: {
authorize: function(secret) {
const dc = this.modelFor('dc').dc.Name;
return get(this, 'feedback').execute(() => {
return get(this, 'repo')
.self(secret, dc)
.then(item => {
get(this, 'settings')
.persist({
token: {
AccessorID: get(item, 'AccessorID'),
SecretID: secret,
},
})
.then(() => {
this.refresh();
});
});
}, 'authorize');
},
},
});

View File

@ -13,6 +13,9 @@ export default Route.extend(WithAclActions, {
replace: true,
},
},
beforeModel: function(transition) {
return this.replaceWith('dc.acls.tokens');
},
model: function(params) {
return hash({
isLoading: false,

View File

@ -0,0 +1,6 @@
import Route from './edit';
import CreatingRoute from 'consul-ui/mixins/creating-route';
export default Route.extend(CreatingRoute, {
templateName: 'dc/acls/policies/edit',
});

View File

@ -0,0 +1,37 @@
import SingleRoute from 'consul-ui/routing/single';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithPolicyActions from 'consul-ui/mixins/policy/with-actions';
export default SingleRoute.extend(WithPolicyActions, {
repo: service('policies'),
tokensRepo: service('tokens'),
datacenterRepo: service('dc'),
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const tokensRepo = get(this, 'tokensRepo');
return this._super(...arguments).then(model => {
return hash({
...model,
...{
datacenters: get(this, 'datacenterRepo').findAll(),
items: tokensRepo.findByPolicy(get(model.item, 'ID'), dc).catch(function(e) {
switch (get(e, 'errors.firstObject.status')) {
case '403':
case '401':
// do nothing the SingleRoute will have caught it already
return;
}
throw e;
}),
},
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,29 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithPolicyActions from 'consul-ui/mixins/policy/with-actions';
export default Route.extend(WithPolicyActions, {
repo: service('policies'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
const repo = get(this, 'repo');
return hash({
...repo.status({
items: repo.findAllByDatacenter(this.modelFor('dc').dc.Name),
}),
isLoading: false,
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,6 @@
import Route from './edit';
import CreatingRoute from 'consul-ui/mixins/creating-route';
export default Route.extend(CreatingRoute, {
templateName: 'dc/acls/tokens/edit',
});

View File

@ -0,0 +1,105 @@
import SingleRoute from 'consul-ui/routing/single';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { set, get } from '@ember/object';
import updateArrayObject from 'consul-ui/utils/update-array-object';
import WithTokenActions from 'consul-ui/mixins/token/with-actions';
const ERROR_PARSE_RULES = 'Failed to parse ACL rules';
const ERROR_NAME_EXISTS = 'Invalid Policy: A Policy with Name';
export default SingleRoute.extend(WithTokenActions, {
repo: service('tokens'),
policiesRepo: service('policies'),
datacenterRepo: service('dc'),
settings: service('settings'),
model: function(params, transition) {
const dc = this.modelFor('dc').dc.Name;
const policiesRepo = get(this, 'policiesRepo');
return this._super(...arguments).then(model => {
return hash({
...model,
...{
// TODO: I only need these to create a new policy
datacenters: get(this, 'datacenterRepo').findAll(),
policy: this.getEmptyPolicy(),
token: get(this, 'settings').findBySlug('token'),
items: policiesRepo.findAllByDatacenter(dc).catch(function(e) {
switch (get(e, 'errors.firstObject.status')) {
case '403':
case '401':
// do nothing the SingleRoute will have caught it already
return;
}
throw e;
}),
},
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
getEmptyPolicy: function() {
const dc = this.modelFor('dc').dc.Name;
return get(this, 'policiesRepo').create({ Datacenter: dc });
},
actions: {
// TODO: Some of this could potentially be moved to the repo services
loadPolicy: function(item, items) {
const repo = get(this, 'policiesRepo');
const dc = this.modelFor('dc').dc.Name;
const slug = get(item, repo.getSlugKey());
repo.findBySlug(slug, dc).then(item => {
updateArrayObject(items, item, repo.getSlugKey());
});
},
remove: function(item, items) {
return items.removeObject(item);
},
clearPolicy: function() {
// TODO: I should be able to reset the ember-data object
// back to it original state?
// possibly Forms could know how to create
const controller = get(this, 'controller');
controller.setProperties({
policy: this.getEmptyPolicy(),
});
},
createPolicy: function(item, policies, success) {
get(this, 'policiesRepo')
.persist(item)
.then(item => {
set(item, 'CreateTime', new Date().getTime());
policies.pushObject(item);
return item;
})
.then(function() {
success();
})
.catch(err => {
if (typeof err.errors !== 'undefined') {
const error = err.errors[0];
let prop;
let message = error.detail;
switch (true) {
case message.indexOf(ERROR_PARSE_RULES) === 0:
prop = 'Rules';
message = error.detail;
break;
case message.indexOf(ERROR_NAME_EXISTS) === 0:
prop = 'Name';
message = message.substr(ERROR_NAME_EXISTS.indexOf(':') + 1);
break;
}
if (prop) {
item.addError(prop, message);
}
} else {
throw err;
}
});
},
},
});

View File

@ -0,0 +1,29 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithTokenActions from 'consul-ui/mixins/token/with-actions';
export default Route.extend(WithTokenActions, {
repo: service('tokens'),
settings: service('settings'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
const repo = get(this, 'repo');
return hash({
...repo.status({
items: repo.findAllByDatacenter(this.modelFor('dc').dc.Name),
}),
isLoading: false,
token: get(this, 'settings').findBySlug('token'),
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -34,7 +34,13 @@ export default Route.extend(WithKvActions, {
...model,
...{
items: repo.findAllBySlug(get(model.parent, 'Key'), dc).catch(e => {
return this.transitionTo('dc.kv.index');
const status = get(e, 'errors.firstObject.status');
switch (status) {
case '403':
return this.transitionTo('dc.acls.tokens');
default:
return this.transitionTo('dc.kv.index');
}
}),
},
});

View File

@ -5,8 +5,8 @@ import { get } from '@ember/object';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
export default Route.extend(WithBlockingActions, {
dcRepo: service('dc'),
repo: service('settings'),
dcRepo: service('dc'),
model: function(params) {
return hash({
item: get(this, 'repo').findAll(),

View File

@ -0,0 +1,28 @@
import Route from '@ember/routing/route';
import { get } from '@ember/object';
import { assert } from '@ember/debug';
import { Promise, hash } from 'rsvp';
export default Route.extend({
// repo: service('repositoryName'),
isCreate: function(params, transition) {
return transition.targetName.split('.').pop() === 'create';
},
model: function(params, transition) {
const repo = get(this, 'repo');
assert(
"`repo` is undefined, please define RepositoryService using `repo: service('repositoryName')`",
typeof repo !== 'undefined'
);
const dc = this.modelFor('dc').dc.Name;
const create = this.isCreate(...arguments);
return hash({
isLoading: false,
create: create,
...repo.status({
item: create
? Promise.resolve(repo.create({ Datacenter: dc }))
: repo.findBySlug(params.id, dc),
}),
});
},
});

View File

@ -0,0 +1,7 @@
import Serializer from './application';
import { PRIMARY_KEY, ATTRS } from 'consul-ui/models/policy';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
attrs: ATTRS,
});

View File

@ -0,0 +1,7 @@
import Serializer from './application';
import { PRIMARY_KEY, ATTRS } from 'consul-ui/models/token';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
attrs: ATTRS,
});

View File

@ -0,0 +1,21 @@
import Service from '@ember/service';
import Evented from '@ember/object/evented';
const buffer = {};
export default Service.extend(Evented, {
// 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];
},
add: function(name, dom) {
this.trigger('add', dom);
buffer[name] = dom;
return dom;
},
remove: function(name) {
buffer[name].remove();
delete buffer[name];
},
});

54
ui-v2/app/services/dom.js Normal file
View File

@ -0,0 +1,54 @@
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { get } from '@ember/object';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
// TODO: Move to utils/dom
import getComponentFactory from 'consul-ui/utils/get-component-factory';
import normalizeEvent from 'consul-ui/utils/dom/normalize-event';
// ember-eslint doesn't like you using a single $ so use double
// use $_ for components
const $$ = qsaFactory();
let $_;
export default Service.extend({
doc: document,
init: function() {
this._super(...arguments);
$_ = getComponentFactory(getOwner(this));
},
normalizeEvent: function() {
return normalizeEvent(...arguments);
},
root: function() {
return get(this, 'doc').documentElement;
},
// TODO: Should I change these to use the standard names
// even though they don't have a standard signature (querySelector*)
elementById: function(id) {
return get(this, 'doc').getElementById(id);
},
elementsByTagName: function(name, context) {
context = typeof context === 'undefined' ? get(this, 'doc') : context;
return context.getElementByTagName(name);
},
elements: function(selector, context) {
return $$(selector, context);
},
element: function(selector, context) {
if (selector.substr(0, 1) === '#') {
return this.elementById(selector.substr(1));
}
// TODO: This can just use querySelector
return [...$$(selector, context)][0];
},
// ember components aren't strictly 'dom-like'
// but if you think of them as a web component 'shim'
// then it makes more sense to think of them as part of the dom
// with traditional/standard web components you wouldn't actually need this
// method as you could just get to their methods from the dom element
component: function(selector, context) {
// TODO: support passing a dom element, when we need to do that
return $_(this.element(selector, context));
},
});

View File

@ -7,6 +7,12 @@ const TYPE_ERROR = 'error';
const defaultStatus = function(type, obj) {
return type;
};
const notificationDefaults = function() {
return {
timeout: 6000,
extendedTimeout: 300,
};
};
export default Service.extend({
notify: service('flashMessages'),
logger: service('logger'),
@ -18,23 +24,29 @@ export default Service.extend({
return (
handle()
//TODO: pass this through to getAction..
.then(target => {
.then(item => {
// TODO right now the majority of `item` is a Transition
// but you can resolve an object
notify.add({
...notificationDefaults(),
type: getStatus(TYPE_SUCCESS),
// here..
action: getAction(),
item: item,
});
})
.catch(e => {
get(this, 'logger').execute(e);
if (e.name === 'TransitionAborted') {
notify.add({
...notificationDefaults(),
type: getStatus(TYPE_SUCCESS),
// and here
action: getAction(),
});
} else {
notify.add({
...notificationDefaults(),
type: getStatus(TYPE_ERROR, e),
action: getAction(),
});

View File

@ -0,0 +1,10 @@
import Service from '@ember/service';
import builderFactory from 'consul-ui/utils/form/builder';
const builder = builderFactory();
export default Service.extend({
// a `get` method is added via the form initializer
// see initializers/form.js
build: function(obj, name) {
return builder(...arguments);
},
});

View File

@ -0,0 +1,58 @@
import Service, { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { typeOf } from '@ember/utils';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/policy';
import { Promise } from 'rsvp';
import statusFactory from 'consul-ui/utils/acls-status';
const status = statusFactory(Promise);
const MODEL_NAME = 'policy';
export default Service.extend({
getModelName: function() {
return MODEL_NAME;
},
getPrimaryKey: function() {
return PRIMARY_KEY;
},
getSlugKey: function() {
return SLUG_KEY;
},
store: service('store'),
status: function(obj) {
return status(obj);
},
translate: function(item) {
return get(this, 'store').translate('policy', get(item, 'Rules'));
},
findAllByDatacenter: function(dc) {
return get(this, 'store').query('policy', {
dc: dc,
});
},
findBySlug: function(slug, dc) {
return get(this, 'store').queryRecord('policy', {
id: slug,
dc: dc,
});
},
create: function(obj) {
return get(this, 'store').createRecord('policy', obj);
},
persist: function(item) {
return item.save();
},
remove: function(obj) {
let item = obj;
if (typeof obj.destroyRecord === 'undefined') {
item = obj.get('data');
}
if (typeOf(item) === 'object') {
item = get(this, 'store').peekRecord('policy', item[PRIMARY_KEY]);
}
return item.destroyRecord().then(item => {
return get(this, 'store').unloadRecord(item);
});
},
invalidate: function() {
get(this, 'store').unloadAll('policy');
},
});

View File

@ -1,38 +1,46 @@
import Service from '@ember/service';
import { Promise } from 'rsvp';
import { get } from '@ember/object';
import getStorage from 'consul-ui/utils/storage/local-storage';
const SCHEME = 'consul';
const storage = getStorage(SCHEME);
export default Service.extend({
// TODO: change name
storage: window.localStorage,
storage: storage,
findHeaders: function() {
// TODO: if possible this should be a promise
const token = get(this, 'storage').getItem('token');
// TODO: Actually this has nothing to do with settings it should be in the adapter,
// which probably can't work with a promise based interface :(
const token = get(this, 'storage').getValue('token');
// TODO: The old UI always sent ?token=
// replicate the old functionality here
// but remove this to be cleaner if its not necessary
return {
'X-Consul-Token': token === null ? '' : token,
'X-Consul-Token': typeof token.SecretID === 'undefined' ? '' : token.SecretID,
};
},
findAll: function(key) {
const token = get(this, 'storage').getItem('token');
return Promise.resolve({ token: token === null ? '' : token });
return Promise.resolve(get(this, 'storage').all());
},
findBySlug: function(slug) {
// TODO: Force localStorage to always be strings...
// const value = get(this, 'storage').getItem(slug);
return Promise.resolve(get(this, 'storage').getItem(slug));
return Promise.resolve(get(this, 'storage').getValue(slug));
},
persist: function(obj) {
const storage = get(this, 'storage');
Object.keys(obj).forEach((item, i) => {
// TODO: ...everywhere
storage.setItem(item, obj[item]);
storage.setValue(item, obj[item]);
});
return Promise.resolve(obj);
},
delete: function(obj) {
return Promise.resolve(get(this, 'storage').removeItem('token'));
// TODO: Loop through and delete the specified keys
if (!Array.isArray(obj)) {
obj = [obj];
}
const storage = get(this, 'storage');
const item = obj.reduce(function(prev, item, i, arr) {
storage.removeValue(item);
return prev;
}, {});
return Promise.resolve(item);
},
});

View File

@ -1,5 +1,7 @@
import Store from 'ember-data/store';
// TODO: These only exist for ACLs, should probably make sure they fail
// nicely if you aren't on ACLs for good DX
export default Store.extend({
// cloning immediately refreshes the view
clone: function(modelName, id) {
@ -16,4 +18,9 @@ export default Store.extend({
);
// TODO: See https://github.com/emberjs/data/blob/7b8019818526a17ee72747bd3c0041354e58371a/addon/-private/system/promise-proxies.js#L68
},
self: function(modelName, token) {
// TODO: no normalization, type it properly for the moment
const adapter = this.adapterFor(modelName);
return adapter.self(this, { modelName: modelName }, token);
},
});

View File

@ -0,0 +1,72 @@
import Service, { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { typeOf } from '@ember/utils';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/token';
import { Promise } from 'rsvp';
import statusFactory from 'consul-ui/utils/acls-status';
const status = statusFactory(Promise);
const MODEL_NAME = 'token';
export default Service.extend({
getModelName: function() {
return MODEL_NAME;
},
getPrimaryKey: function() {
return PRIMARY_KEY;
},
getSlugKey: function() {
return SLUG_KEY;
},
status: function(obj) {
return status(obj);
},
self: function(secret, dc) {
return get(this, 'store').self(this.getModelName(), {
secret: secret,
dc: dc,
});
},
clone: function(item) {
return get(this, 'store').clone(this.getModelName(), get(item, PRIMARY_KEY));
},
findByPolicy: function(id, dc) {
return get(this, 'store').query(this.getModelName(), {
policy: id,
dc: dc,
});
},
// TODO: RepositoryService
store: service('store'),
findAllByDatacenter: function(dc) {
return get(this, 'store').query(this.getModelName(), {
dc: dc,
});
},
findBySlug: function(slug, dc) {
return get(this, 'store').queryRecord(this.getModelName(), {
id: slug,
dc: dc,
});
},
create: function(obj) {
// TODO: This should probably return a Promise
return get(this, 'store').createRecord(this.getModelName(), obj);
},
persist: function(item) {
return item.save();
},
remove: function(obj) {
let item = obj;
if (typeof obj.destroyRecord === 'undefined') {
item = obj.get('data');
}
if (typeOf(item) === 'object') {
item = get(this, 'store').peekRecord(this.getModelName(), item[this.getPrimaryKey()]);
}
return item.destroyRecord().then(item => {
return get(this, 'store').unloadRecord(item);
});
},
invalidate: function() {
get(this, 'store').unloadAll(this.getModelName());
},
});

View File

@ -6,32 +6,7 @@
@import 'ember-power-select';
@import 'components/breadcrumbs';
@import 'components/anchors';
@import 'components/buttons';
@import 'components/tabs';
@import 'components/pill';
@import 'components/table';
@import 'components/form-elements';
@import 'components/tabular-collection';
@import 'components/list-collection';
@import 'components/product';
@import 'components/healthcheck-status';
@import 'components/healthchecked-resource';
@import 'components/freetext-filter';
@import 'components/filter-bar';
@import 'components/tomography-graph';
@import 'components/action-group';
@import 'components/flash-message';
@import 'components/code-editor';
@import 'components/confirmation-dialog';
@import 'components/feedback-dialog';
@import 'components/notice';
@import 'components/with-tooltip';
@import 'components/index';
@import 'core/typography';
@import 'core/layout';
@ -40,3 +15,5 @@
@import 'routes/dc/intention/index';
@import 'routes/dc/kv/index';
@import 'routes/dc/acls/index';
@import 'routes/dc/acls/tokens/index';
@import 'routes/dc/acls/policies/index';

View File

@ -1,5 +1,14 @@
$star-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="10" height="9" viewBox="0 0 10 9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M5 7.196L7.575 8.75l-.683-2.93 2.275-1.97-2.996-.254L5 .833 3.83 3.596.832 3.85l2.275 1.97-.683 2.93z"/></defs><use fill="%23FAC402" xlink:href="%23a" fill-rule="evenodd"/></svg>');
$eye-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="16" height="8" viewBox="0 0 16 8" xmlns="http://www.w3.org/2000/svg"><path d="M10.229 1.301A3.493 3.493 0 0 1 11.5 4a3.493 3.493 0 0 1-1.271 2.699c1.547-.431 3.008-1.326 4.393-2.699-1.385-1.373-2.846-2.268-4.393-2.699zM5.771 6.7A3.493 3.493 0 0 1 4.5 4c0-1.086.495-2.057 1.271-2.699C4.224 1.732 2.763 2.627 1.378 4c1.385 1.373 2.846 2.268 4.393 2.699zM8 8C5.054 8 2.388 6.667 0 4c2.388-2.667 5.054-4 8-4 2.946 0 5.612 1.333 8 4-2.388 2.667-5.054 4-8 4zm.965-4.25a1 1 0 1 0 .07-2 1 1 0 0 0-.07 2z" fill="%237C8896" fill-rule="nonzero"/></svg>');
$chevron-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="10" height="6" viewBox="0 0 10 6" xmlns="http://www.w3.org/2000/svg"><path d="M5.001 3.515L8.293.287a1.014 1.014 0 0 1 1.414 0 .967.967 0 0 1 0 1.386L5.71 5.595a1.014 1.014 0 0 1-1.414 0L.293 1.674a.967.967 0 0 1 0-1.387 1.014 1.014 0 0 1 1.414 0l3.294 3.228z" fill="%23000" fill-rule="nonzero"/></svg>');
$cancel-plain-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="24" height="24" viewport="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="%23373a42"/></svg>');
$loading-svg: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24" class="structure-icon-loading"><style>.structure-icon-loading-base{opacity:.1}.structure-icon-loading-progress{animation:structure-icon-loading-fancy-spin 3s infinite linear;opacity:.25;stroke-dasharray:0 44;stroke-dashoffset:0;stroke-linecap:round;transform-origin:50% 50%}@keyframes structure-icon-loading-fancy-spin{0%{stroke-dasharray:0 44;stroke-dashoffset:0}25%{stroke-dasharray:33 11;stroke-dashoffset:-40}50%{stroke-dasharray:0 44;stroke-dashoffset:-110}75%{stroke-dasharray:33 11;stroke-dashoffset:-150}to{stroke-dasharray:0 44;stroke-dashoffset:-220}}@keyframes structure-icon-loading-simple-spin{0%{transform:rotate(0deg)}to{transform:rotate(360deg)}}</style><defs><path stroke="%23fff" stroke-width="3" fill="none" id="structure-icon-loading" d="M12 5l6 3v8l-6 3-6-3V8z"/></defs><use xlink:href="%23structure-icon-loading" class="structure-icon-loading-base"/><use xlink:href="%23structure-icon-loading" class="structure-icon-loading-progress"/></svg>');
$hashicorp-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 107 114" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"><path d="M44.54 0L0 25.69V87.41l16.73 9.66V35.35L44.54 19.3z"/><path d="M62.32 0v49.15H44.54V30.81L27.8 40.47v62.97l16.74 9.68V64.11h17.78v18.22l16.73-9.66V9.66z"/><path d="M62.32 113.14l44.54-25.69V25.73l-16.74-9.66v61.72l-27.8 16.05z"/></svg>');
$consul-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M8.693 10.707a1.862 1.862 0 1 1-.006-3.724 1.862 1.862 0 0 1 .006 3.724" fill="%23961D59"/><path d="M12.336 9.776a.853.853 0 1 1 0-1.707.853.853 0 0 1 0 1.707M15.639 10.556a.853.853 0 1 1 .017-.07c-.01.022-.01.044-.017.07M14.863 8.356a.855.855 0 0 1-.925-1.279.855.855 0 0 1 1.559.255c.024.11.027.222.009.333a.821.821 0 0 1-.642.691M17.977 10.467a.849.849 0 1 1-1.67-.296.849.849 0 0 1 .982-.692c.433.073.74.465.709.905a.221.221 0 0 0-.016.076M17.286 8.368a.853.853 0 1 1-.279-1.684.853.853 0 0 1 .279 1.684M16.651 13.371a.853.853 0 1 1-1.492-.828.853.853 0 0 1 1.492.828M16.325 5.631a.853.853 0 1 1-.84-1.485.853.853 0 0 1 .84 1.485" fill="%23D62783"/><path d="M8.842 17.534c-4.798 0-8.687-3.855-8.687-8.612C.155 4.166 4.045.31 8.842.31a8.645 8.645 0 0 1 5.279 1.77l-1.056 1.372a6.987 6.987 0 0 0-7.297-.709 6.872 6.872 0 0 0 0 12.356 6.987 6.987 0 0 0 7.297-.709l1.056 1.374a8.66 8.66 0 0 1-5.279 1.77z" fill="%23D62783" fill-rule="nonzero"/></g></svg>');
$nomad-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="%231F9967" d="M11.569 6.871v2.965l-2.064 1.192-1.443-.894v7.74l.04.002 7.78-4.47V4.48h-.145z"/><path fill="%2325BA81" d="M7.997 0L.24 4.481l5.233 3.074 1.06-.645 2.57 1.435v-2.98l2.465-1.481v2.987l4.314-2.391v-.011z"/><path fill="%2325BA81" d="M7.02 9.54v2.976l-2.347 1.488V8.05l.89-.548L.287 4.48.24 4.48v8.926l7.821 4.467v-7.74z"/></g></svg>');
$terraform-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%235C4EE5" d="M5.51 3.15l4.886 2.821v5.644L5.509 8.792z"/><path fill="%234040B2" d="M10.931 5.971v5.644l4.888-2.823V3.15z"/><path fill="%235C4EE5" d="M.086 0v5.642l4.887 2.823V2.82zM5.51 15.053l4.886 2.823v-5.644l-4.887-2.82z"/></g></svg>');
$terraform-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%235C4EE5" d="M5.51 3.15l4.886 2.821v5.644L5.509 8.792z"/><path fill="%234040B2" d="M10.931 5.971v5.644l4.888-2.823V3.15z"/><path fill="%235C4EE5" d="M.086 0v5.642l4.887 2.823V2.82zM5.51 15.053l4.886 2.823v-5.644l-4.887-2.82z"/></g></svg>');

View File

@ -33,3 +33,10 @@
-ms-user-select: none;
user-select: none;
}
%user-select-text {
-webkit-touch-callout: default;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}

View File

@ -74,7 +74,9 @@ fieldset {
border: none;
width: 100%;
}
a {
a,
input[type='checkbox'],
input[type='radio'] {
cursor: pointer;
}
hr {

View File

@ -3,10 +3,10 @@ $typo-family-sans: BlinkMacSystemFont, -apple-system, 'Segoe UI', 'Roboto', 'Oxy
$typo-family-mono: monospace;
$typo-size-000: 16px;
$typo-size-100: 3.5rem;
$typo-size-200: 2.5rem;
$typo-size-300: 2.2rem;
$typo-size-400: 1.5rem;
$typo-size-500: 1.125rem;
$typo-size-200: 1.8rem;
$typo-size-300: 1.3rem;
$typo-size-400: 1.2rem;
$typo-size-500: 1rem;
$typo-size-600: 0.875rem;
$typo-size-700: 0.8125rem;
$typo-size-800: 0.75rem;

View File

@ -1 +1,2 @@
@import './base-variables';
@import './semantic-variables';

View File

@ -0,0 +1,3 @@
$typo-header-100: $typo-size-200;
$typo-header-200: $typo-size-300;
$typo-header-300: $typo-size-500;

View File

@ -27,6 +27,7 @@
z-index: -1;
top: 0;
}
/* this is actually the group */
%action-group ul {
position: absolute;
right: -10px;
@ -71,6 +72,6 @@
%action-group input[type='radio']:checked ~ .with-confirmation > ul {
display: block;
}
%action-group input[type='radio']:checked ~ label[for="actions_close"] {
%action-group input[type='radio']:checked ~ label[for='actions_close'] {
z-index: 1;
}

View File

@ -1,7 +1,17 @@
%action-group label:first-of-type {
@extend %toggle-button;
}
%action-group input[type='radio']:checked + label:first-of-type {
background-color: $ui-gray-050;
}
%action-group label {
border-radius: $radius-small;
cursor: pointer;
}
%action-group label::after,
%action-group label::before,
%action-group::before {
@extend %with-dot;
}
%action-group ul {
border: $decor-border-100;
border-radius: $radius-small;
@ -15,13 +25,6 @@
%action-group ul::before {
border-color: $ui-color-action;
}
%action-group input[type='radio']:checked + label:first-of-type,
%action-group label:first-of-type:hover {
background-color: $ui-gray-050;
}
%action-group label:first-of-type:active {
background-color: $ui-gray-100;
}
%action-group li a:hover {
background-color: $ui-color-action;
color: $ui-white;
@ -30,8 +33,3 @@
%action-group ul::before {
background-color: $ui-white;
}
%action-group label::after,
%action-group label::before,
%action-group::before {
@extend %with-dot;
}

View File

@ -1,18 +1,18 @@
@import './anchors/index';
main a {
%main-content a {
color: $ui-gray-900;
}
main a[rel*='help'] {
%main-content a[rel*='help'] {
@extend %with-info;
}
main label a[rel*='help'] {
%main-content label a[rel*='help'] {
color: $ui-gray-400;
}
[role='tabpanel'] > p:only-child [rel*='help']::after {
content: none;
}
main p a,
main dd a {
%main-content p a,
%main-content dd a {
@extend %anchor;
}

View File

@ -0,0 +1,40 @@
@import './app-view/index';
@import './filter-bar/index';
@import './buttons/index';
main {
@extend %app-view;
}
%app-view > div > div {
@extend %app-content;
}
%app-view header form {
@extend %filter-bar;
}
@media #{$--lt-spacious-page-header} {
%app-view header .actions {
margin-top: 5px;
}
}
%app-view h1 span {
@extend %with-external-source-icon;
}
%app-view header .actions a,
%app-view header .actions button {
@extend %button-compact;
}
%app-content div > dl {
@extend %form-row;
}
[role='tabpanel'] > p:only-child,
.template-error > div,
%app-content > p:only-child,
%app-view > div.disabled > div,
%app-view.empty > div {
@extend %app-content-empty;
}
[role='tabpanel'] > *:first-child {
margin-top: 1.25em;
}
%app-view > div.disabled > div {
margin-top: 0 !important;
}

View File

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

View File

@ -0,0 +1,52 @@
/* layout */
%app-view header > div:last-of-type > div:first-child {
flex-grow: 1;
}
%app-view {
position: relative;
}
%app-view header .actions {
float: right;
display: flex;
align-items: flex-start;
}
/* units */
%app-view {
margin-top: 50px;
}
%app-view header + div > *:first-child {
margin-top: 1.8em;
}
%app-view h2 {
padding-bottom: 0.2em;
margin-bottom: 1.1em;
}
%app-view header .actions > *:not(:last-child) {
margin-right: 12px;
}
// content
%app-content div > dl > dt {
position: absolute;
}
%app-content div > dl {
position: relative;
}
%app-content-empty {
margin-top: 0;
padding: 50px;
text-align: center;
}
%app-content form:not(:last-child) {
margin-bottom: 2.2em;
}
%app-content div > dl > dt {
width: 140px;
}
%app-content div > dl > dd {
padding-left: 140px;
}
%app-content div > dl > * {
min-height: 1em;
margin-bottom: 0.4em;
}

View File

@ -0,0 +1,21 @@
%app-view h2,
%app-view header > div:last-of-type {
border-bottom: $decor-border-100;
}
%app-view header > div:last-of-type,
%app-view h2 {
border-color: $keyline-light;
}
%app-content div > dl > dd {
color: $ui-gray-400;
}
[role='tabpanel'] > p:only-child,
.template-error > div {
background-color: $ui-gray-050;
color: $ui-gray-400;
}
%app-content > p:only-child,
%app-view > div.disabled > div {
background-color: $ui-gray-050;
color: $ui-gray-400;
}

View File

@ -1,6 +1,6 @@
%breadcrumbs {
position: absolute;
top: -35px; // %app-view:margin-top - 15px;
top: -38px; // %app-view:margin-top - 15px;
}
%breadcrumbs ol {
display: flex;

View File

@ -1,17 +1,40 @@
%button {
position: relative;
}
%button .progress.indeterminate {
position: absolute;
top: 50%;
left: 50%;
margin-left: -12px;
margin-top: -12px;
}
%button:disabled .progress + * {
visibility: hidden;
}
%button:empty {
padding-right: 0 !important;
padding-left: 18px !important;
margin-right: 5px;
}
%button:empty::before {
left: 1px;
}
%button:not(:empty) {
display: inline-flex;
text-align: center;
justify-content: center;
align-items: center;
padding: calc(0.375em - 1px) calc(2.2em - 1px);
height: 2.5em;
height: 2.55em;
min-width: 100px;
}
%button:not(:last-child) {
margin-right: 7px;
margin-right: 8px;
}
%button-compact {
// @extend %button;
padding-left: calc(1.75em - 1px);
padding-right: calc(1.75em - 1px);
height: 2.1em;
padding-left: calc(1.6em - 1px) !important;
padding-right: calc(1.6em - 1px) !important;
padding-top: calc(0.35em - 1px) !important;
height: 2.3em !important;
}

View File

@ -10,6 +10,10 @@
}
%copy-button {
@extend %button, %with-clipboard;
min-height: 17px;
}
%copy-button:not(:empty) {
padding-left: 38px !important;
}
%primary-button,
%secondary-button,

View File

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

View File

@ -0,0 +1,9 @@
%checkbox-group span {
display: inline-block;
margin-left: 10px;
min-width: 50px;
}
%checkbox-group label {
margin-right: 10px;
white-space: nowrap;
}

View File

@ -0,0 +1,3 @@
%checkbox-group label {
cursor: pointer;
}

View File

@ -2,7 +2,7 @@
div.with-confirmation {
@extend %confirmation-dialog, %confirmation-dialog-inline;
}
table div.with-confirmation.confirming {
table td > div.with-confirmation.confirming {
position: absolute;
right: 0;
}

View File

@ -2,7 +2,10 @@
float: right;
}
%confirmation-dialog-inline p {
margin-right: 1em;
margin-right: 12px;
padding-left: 12px;
padding-top: 5px;
padding-bottom: 5px;
margin-bottom: 0;
}
%confirmation-dialog-inline {

View File

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

View File

@ -0,0 +1,13 @@
%dom-recycling-table {
position: relative;
}
%dom-recycling-table tr {
display: flex;
}
%dom-recycling-table tr > * {
flex: 1 0 auto;
}
%dom-recycling-table tbody {
/* important required as ember-collection will inline an overflow: visible*/
overflow-x: hidden !important;
}

View File

@ -2,3 +2,6 @@
.flash-message {
@extend %flash-message;
}
%flash-message.exiting {
@extend %blink-in-fade-out;
}

View File

@ -1,18 +1,38 @@
/*TODO: This remains a mix of form-elements */
/* form-elements should probably be a collection of these */
@import './form-elements/index';
@import './toggle/index';
.type-toggle {
@extend %toggle;
}
@import './radio-group/index';
@import './checkbox-group/index';
label span {
@extend %user-select-none;
}
.has-error {
@extend %form-element-error;
}
%app-content .type-text,
%app-content .type-toggle {
%modal-dialog .type-text,
%app-content .type-text {
@extend %form-element;
}
.type-toggle {
@extend %form-element, %toggle;
}
%form-element,
%radio-group,
%checkbox-group,
form table,
%app-content form dl {
@extend %form-row;
}
%app-content [role='radiogroup'] {
@extend %radio-group;
}
%radio-group label {
@extend %form-element;
}
.checkbox-group {
@extend %checkbox-group;
}
%toggle + .checkbox-group {
margin-top: -1em;
}

View File

@ -1,9 +1,18 @@
%form-row {
margin-bottom: 1.4em;
}
%form-element {
@extend %form-row;
}
%form-element,
%form-element > em,
%form-element > span,
%form-element textarea {
display: block;
}
%form-element a {
display: inline;
}
%form-element > em > code {
display: inline-block;
}
@ -18,6 +27,10 @@
%form-element > span {
margin-bottom: 0.5em;
}
%form-element > span + em {
margin-top: -0.5em;
margin-bottom: 0.5em;
}
%form-element textarea {
max-width: 100%;
min-width: 100%;
@ -47,30 +60,3 @@
%form-element > span {
margin-bottom: 0.4em !important;
}
%form-element,
%radio-group {
margin-bottom: 1.55em;
}
%radio-group {
overflow: hidden;
}
%radio-group label {
float: left;
}
%radio-group label > span {
float: right;
}
%radio-group {
padding-left: 1px;
}
%radio-group label:not(:last-child) {
margin-right: 25px;
}
%radio-group label > span {
margin-left: 1em;
margin-top: 0.2em;
}
%radio-group label,
%radio-group label > span {
margin-bottom: 0 !important;
}

View File

@ -1,5 +1,5 @@
%radio-group label {
@extend %form-element;
%form-element > strong {
@extend %with-error;
}
%form-element-error > input,
%form-element-error > textarea {
@ -10,7 +10,7 @@
%form-element textarea {
-moz-appearance: none;
-webkit-appearance: none;
box-shadow: inset 0 4px 1px rgba(0, 0, 0, .06);
box-shadow: inset 0 4px 1px rgba(0, 0, 0, 0.06);
border-radius: $decor-radius-100;
border: $decor-border-100;
}
@ -25,6 +25,9 @@
%form-element-error > input {
border-color: $ui-color-failure !important;
}
%form-element > strong {
color: $ui-color-failure;
}
%form-element > em {
color: $ui-gray-400;
}

View File

@ -1,27 +1,34 @@
/*TODO: The old pseudo-icon was to specific */
/* make a temporary one with the -- prefix */
/* to make it more reusable temporarily */
%bg-icon {
background-repeat: no-repeat;
background-position: center;
}
%--pseudo-icon {
display: block;
display: inline-block;
content: '';
visibility: visible;
position: absolute;
top: 50%;
background-repeat: no-repeat;
background-position: center center;
background-position: center;
}
%pseudo-icon-bg-img {
@extend %--pseudo-icon;
position: relative;
background-size: contain;
background-color: transparent;
}
%pseudo-icon-css {
@extend %--pseudo-icon;
display: block;
position: absolute;
top: 50%;
width: 1em;
height: 1em;
margin-top: -0.6em;
background-color: currentColor;
}
/* %pseudo-icon-mask, %pseudo-icon-overlay ?*/
%pseudo-icon {
@extend %pseudo-icon-css;
}
@ -145,6 +152,36 @@
width: 16px;
height: 16px;
}
/*TODO: All chevrons need merging */
%with-chevron-down::before {
@extend %pseudo-icon-bg-img;
background-image: $chevron-svg;
width: 10px;
height: 6px;
}
%with-star-before::before,
%with-star-after::after {
@extend %pseudo-icon-bg-img;
background-image: $star-svg;
width: 10px;
height: 9px;
}
%with-star-before::before {
padding-right: 12px;
}
%with-star-after::after {
padding-left: 22px;
}
%with-star {
@extend %with-star-before;
}
%with-eye::before {
@extend %pseudo-icon-bg-img;
background-image: $eye-svg;
width: 16px;
height: 8px;
padding-right: 12px;
}
%with-tick {
@extend %pseudo-icon;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="10" height="8" xmlns="http://www.w3.org/2000/svg"><path d="M8.95 0L10 .985 3.734 8 0 4.737l.924-1.11 2.688 2.349z" fill="%23FFF"/></svg>');
@ -216,3 +253,11 @@
@extend %with-minus;
border-radius: 20%;
}
%with-error {
position: relative;
padding-left: 18px;
}
%with-error::before {
@extend %with-cross;
margin-top: -0.5em;
}

View File

@ -0,0 +1,31 @@
@import './breadcrumbs';
@import './anchors';
@import './progress';
@import './buttons';
@import './toggle-button';
@import './secret-button';
@import './tabs';
@import './pill';
@import './table';
@import './form-elements';
@import './tabular-details';
@import './tabular-collection';
@import './list-collection';
@import './app-view';
@import './product';
@import './healthcheck-status';
@import './healthchecked-resource';
@import './freetext-filter';
@import './filter-bar';
@import './tomography-graph';
@import './action-group';
@import './flash-message';
@import './code-editor';
@import './confirmation-dialog';
@import './feedback-dialog';
@import './modal-dialog';
@import './notice';
@import './with-tooltip';

View File

@ -0,0 +1,13 @@
@import './modal-dialog/index';
[role='dialog'] {
@extend %modal-dialog;
}
input[name='modal'] {
@extend %modal-control;
}
html.template-with-modal {
@extend %with-modal;
}
%modal-dialog table {
min-height: 149px;
}

View File

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

View File

@ -0,0 +1,70 @@
%modal-dialog > div > div {
@extend %modal-window;
}
%with-modal {
overflow: hidden;
}
%modal-dialog {
z-index: 10000;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
%modal-control,
%modal-control + * {
display: none;
}
%modal-control:checked + * {
display: block;
}
%modal-dialog > label {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
%modal-dialog > div {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
%modal-window.overflowing {
overflow: auto;
height: 100%;
}
%modal-window {
max-width: 855px;
position: relative;
z-index: 1;
}
%modal-window > * {
padding-left: 15px;
padding-right: 15px;
}
%modal-window > div {
padding: 20px 23px;
}
%modal-window > footer,
%modal-window > header {
padding-top: 12px;
padding-bottom: 10px;
}
%modal-window table {
height: 150px !important;
}
%modal-window tbody {
max-height: 100px;
}
%modal-window > header {
position: relative;
}
%modal-window > header [for='modal_close'] {
float: right;
text-indent: -9000px;
width: 23px;
height: 23px;
}

View File

@ -0,0 +1,42 @@
%modal-dialog > label {
background-color: rgba($ui-white, 0.9);
}
%modal-window {
box-shadow: 2px 8px 8px 0 rgba($ui-black, 0.1);
}
%modal-window {
/*%frame-gray-000*/
background-color: $ui-white;
border: $decor-border-100;
border-color: $ui-gray-300;
}
%modal-window > footer,
%modal-window > header {
/*%frame-gray-000*/
border: 0 solid;
background-color: $ui-gray-050;
border-color: $ui-gray-300;
}
%modal-window > footer {
border-top-width: 1px;
}
%modal-window > header {
border-bottom-width: 1px;
}
%modal-window.warning > header {
@extend %with-warning;
text-indent: 20px;
}
%modal-window > header [for='modal_close'] {
@extend %bg-icon;
background-image: $cancel-plain-svg;
background-size: 80%;
cursor: pointer;
/*%frame-gray-000*/
background-color: $ui-gray-050;
border: $decor-border-100;
border-color: $ui-gray-300;
border-radius: $decor-radius-100;
}

View File

@ -2,3 +2,9 @@
.notice.warning {
@extend %notice-warning;
}
.notice.info {
@extend %notice-info;
}
.notice.policy-management {
@extend %notice-highlight;
}

View File

@ -1,10 +1,11 @@
%notice::before {
left: 20px;
top: 18px;
margin-top: 0;
}
%notice {
position: relative;
padding: 1em;
padding-left: 45px;
}
%notice::before {
position: absolute;
left: 20px;
top: 18px;
margin-top: 0;
}

Some files were not shown because too many files have changed in this diff Show More