Skip to content

Commit

Permalink
UI: ACL Roles (#5635)
Browse files Browse the repository at this point in the history
Adds support for ACL Roles and Service Identities CRUD, along with necessary changes to Tokens, and the CSS improvements required.

Also includes refinements/improvements for easier testing of deeply nested components.

1. ember-data adapter/serializer/model triplet for Roles
2. repository, form/validations and searching filter for Roles
3. Moves potentially, repeated, or soon to to repeated functionality
into a mixin (mainly for 'many policy' relationships)
4. A few styling tweaks for little edge cases around roles
5. Router additions, Route, Controller and templates for Roles

Also see: 

* UI: ACL Roles cont. plus Service Identities (#5661 and #5720)
  • Loading branch information
johncowen authored May 1, 2019
1 parent bc22ab5 commit 0ff88f1
Show file tree
Hide file tree
Showing 208 changed files with 4,141 additions and 944 deletions.
72 changes: 72 additions & 0 deletions ui-v2/app/adapters/role.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Adapter, {
REQUEST_CREATE,
REQUEST_UPDATE,
DATACENTER_QUERY_PARAM as API_DATACENTER_KEY,
} from './application';

import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/role';
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 WithPolicies from 'consul-ui/mixins/policy/as-many';

export default Adapter.extend(WithPolicies, {
urlForQuery: function(query, modelName) {
return this.appendURL('acl/roles', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
if (typeof query.id === 'undefined') {
throw new Error('You must specify an id');
}
return this.appendURL('acl/role', [query.id], this.cleanQuery(query));
},
urlForCreateRecord: function(modelName, snapshot) {
return this.appendURL('acl/role', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForUpdateRecord: function(id, modelName, snapshot) {
return this.appendURL('acl/role', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForDeleteRecord: function(id, modelName, snapshot) {
return this.appendURL('acl/role', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
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);
},
dataForRequest: function(params) {
const data = this._super(...arguments);
switch (params.requestType) {
case REQUEST_UPDATE:
case REQUEST_CREATE:
return data.role;
}
return data;
},
});
22 changes: 4 additions & 18 deletions ui-v2/app/adapters/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ 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 WithPolicies from 'consul-ui/mixins/policy/as-many';
import WithRoles from 'consul-ui/mixins/role/as-many';

import { get } from '@ember/object';

const REQUEST_CLONE = 'cloneRecord';
const REQUEST_SELF = 'querySelf';

export default Adapter.extend({
export default Adapter.extend(WithRoles, WithPolicies, {
store: service('store'),
cleanQuery: function(_query) {
const query = this._super(...arguments);
Expand Down Expand Up @@ -108,10 +111,6 @@ export default Adapter.extend({
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')
Expand Down Expand Up @@ -169,19 +168,6 @@ export default Adapter.extend({
}
// 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:
Expand Down
113 changes: 113 additions & 0 deletions ui-v2/app/components/child-selector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { Promise } from 'rsvp';

import SlotsMixin from 'block-slots';
import WithListeners from 'consul-ui/mixins/with-listeners';

export default Component.extend(SlotsMixin, WithListeners, {
onchange: function() {},

error: function() {},
type: '',

dom: service('dom'),
container: service('search'),
formContainer: service('form'),

item: alias('form.data'),

selectedOptions: alias('items'),

init: function() {
this._super(...arguments);
this.searchable = get(this, 'container').searchable(get(this, 'type'));
this.form = get(this, 'formContainer').form(get(this, 'type'));
this.form.clear({ Datacenter: get(this, 'dc') });
},
options: computed('selectedOptions.[]', 'allOptions.[]', function() {
// It's not massively important here that we are defaulting `items` and
// losing reference as its just to figure out the diff
let options = get(this, 'allOptions') || [];
const items = get(this, 'selectedOptions') || [];
if (get(items, 'length') > 0) {
// find a proper ember-data diff
options = options.filter(item => !items.findBy('ID', get(item, 'ID')));
this.searchable.add(options);
}
return options;
}),
actions: {
search: function(term) {
// TODO: make sure we can either search before things are loaded
// or wait until we are loaded, guess power select take care of that
return new Promise(resolve => {
const remove = this.listen(this.searchable, 'change', function(e) {
remove();
resolve(e.target.data);
});
this.searchable.search(term);
});
},
reset: function() {
get(this, 'form').clear({ Datacenter: get(this, 'dc') });
},
open: function() {
if (!get(this, 'allOptions.closed')) {
set(this, 'allOptions', get(this, 'repo').findAllByDatacenter(get(this, 'dc')));
}
},
save: function(item, items, success = function() {}) {
// Specifically this saves an 'new' option/child
// and then adds it to the selectedOptions, not options
const repo = get(this, 'repo');
set(item, 'CreateTime', new Date().getTime());
// TODO: temporary async
// this should be `set(this, 'item', repo.persist(item));`
// need to be sure that its saved before adding/closing the modal for now
// and we don't open the modal on prop change yet
item = repo.persist(item);
this.listen(item, 'message', e => {
this.actions.change.bind(this)(
{
target: {
name: 'items[]',
value: items,
},
},
items,
e.data
);
success();
});
this.listen(item, 'error', this.error.bind(this));
},
remove: function(item, items) {
const prop = get(this, 'repo').getSlugKey();
const value = get(item, prop);
const pos = items.findIndex(function(item) {
return get(item, prop) === value;
});
if (pos !== -1) {
return items.removeAt(pos, 1);
}
this.onchange({ target: this });
},
change: function(e, value, item) {
const event = get(this, 'dom').normalizeEvent(...arguments);
const items = value;
switch (event.target.name) {
case 'items[]':
set(item, 'CreateTime', new Date().getTime());
// this always happens synchronously
items.pushObject(item);
// TODO: Fire a proper event
this.onchange({ target: this });
break;
default:
}
},
},
});
46 changes: 40 additions & 6 deletions ui-v2/app/components/code-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,57 @@ const DEFAULTS = {
};
export default Component.extend({
settings: service('settings'),
dom: service('dom'),
helper: service('code-mirror/linter'),
classNames: ['code-editor'],
readonly: false,
syntax: '',
onchange: function(value) {
get(this, 'settings').persist({
'code-editor': value,
});
this.setMode(value);
},
// TODO: Change this to oninput to be consistent? We'll have to do it throughout the templates
onkeyup: function() {},
oninput: function() {},
init: function() {
this._super(...arguments);
set(this, 'modes', get(this, 'helper').modes());
},
didReceiveAttrs: function() {
this._super(...arguments);
const editor = get(this, 'editor');
if (editor) {
editor.setOption('readOnly', get(this, 'readonly'));
}
},
setMode: function(mode) {
set(this, 'options', {
...DEFAULTS,
mode: mode.mime,
readOnly: get(this, 'readonly'),
});
const editor = get(this, 'editor');
editor.setOption('mode', mode.mime);
get(this, 'helper').lint(editor, mode.mode);
set(this, 'mode', mode);
},
willDestroyElement: function() {
this._super(...arguments);
if (this.observer) {
this.observer.disconnect();
}
},
didInsertElement: function() {
this._super(...arguments);
const $code = get(this, 'dom').element('textarea ~ pre code', get(this, 'element'));
if ($code.firstChild) {
this.observer = new MutationObserver(([e]) => {
this.oninput(set(this, 'value', e.target.wholeText));
});
this.observer.observe($code, {
attributes: false,
subtree: true,
childList: false,
characterData: true,
});
set(this, 'value', $code.firstChild.wholeText);
}
set(this, 'editor', get(this, 'helper').getEditor(this.element));
get(this, 'settings')
.findBySlug('code-editor')
Expand All @@ -54,4 +80,12 @@ export default Component.extend({
didAppear: function() {
get(this, 'editor').refresh();
},
actions: {
change: function(value) {
get(this, 'settings').persist({
'code-editor': value,
});
this.setMode(value);
},
},
});
42 changes: 42 additions & 0 deletions ui-v2/app/components/form-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Component from '@ember/component';
import SlotsMixin from 'block-slots';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { alias } from '@ember/object/computed';
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, {
onreset: function() {},
onchange: function() {},
onerror: function() {},
onsuccess: function() {},

data: alias('form.data'),
item: alias('form.data'),
// TODO: Could probably alias item
// or just use data/value instead

dom: service('dom'),
container: service('form'),

actions: {
change: function(e, value, item) {
let event = get(this, 'dom').normalizeEvent(e, value);
const matches = [...event.target.name.matchAll(propRe)];
const prop = matches[matches.length - 1][0];
event = get(this, 'dom').normalizeEvent(
`${get(this, 'type')}[${prop}]`,
event.target.value,
event.target
);
const form = get(this, 'form');
try {
form.handleEvent(event);
this.onchange({ target: this });
} catch (err) {
throw err;
}
},
},
});
10 changes: 6 additions & 4 deletions ui-v2/app/components/modal-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ export default DomBufferComponent.extend(SlotsMixin, WithResizing, {
_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);
if (dialogPanel) {
const overflowing = get(this, 'overflowingClass');
if (dialogPanel.classList.contains(overflowing)) {
dialogPanel.classList.remove(overflowing);
}
}
// TODO: should we make a didDisappear?
get(this, 'dom')
Expand Down Expand Up @@ -108,7 +110,7 @@ export default DomBufferComponent.extend(SlotsMixin, WithResizing, {
if (get(e, 'target.checked')) {
this._open(e);
} else {
this._close();
this._close(e);
}
},
close: function() {
Expand Down
Loading

0 comments on commit 0ff88f1

Please sign in to comment.