UI - replication path filtering (#7620)
* rename mount-filter-config models, components, serializer, adapters to path-filter-config * move search-select component to core addon * add js class for search-select-placeholder and sort out power-select deps for moving to the core component * expose oninput from powerselect through search-select * don't fetch mounts in the replication routes * remove toggle from add template * start cross-namespace fetching * group options and set up for namespace fetch via power-select search prop * add and style up radio-card CSS component * add xlm size for icons between l and xl * copy defaults so they're not getting mutated * finalize cross-namespace fetching and getting that to work with power-select * when passing options but no models, format the options in search select so that they render properly in the list * tint the background of a selected radio card * default to null mode and uniq options in search-select * finish styling radio-card * format inputValues when first rendering the component if options are being passed from outside * treat mode:null as deleting existing config which simplifies save logic * correctly prune the auto complete list since path-filter-config-list handles all of that and finish styling * remove old component * add search debounce and fix linting * update search-select docs * updating tests * support grouped options for when to show the create prompt * update and add tests for path-filter-config-list * fix tests for search-select and path-filter-config-list * the new api uses allow/deny instead of whitelist/blacklist
This commit is contained in:
parent
ee2e3fd75d
commit
eae5e114ba
|
@ -2,7 +2,7 @@ import ApplicationAdapter from './application';
|
|||
|
||||
export default ApplicationAdapter.extend({
|
||||
url(id) {
|
||||
return `${this.buildURL()}/replication/performance/primary/mount-filter/${id}`;
|
||||
return `${this.buildURL()}/replication/performance/primary/paths-filter/${id}`;
|
||||
},
|
||||
|
||||
findRecord(store, type, id) {
|
|
@ -2,9 +2,7 @@ import DS from 'ember-data';
|
|||
const { attr } = DS;
|
||||
|
||||
export default DS.Model.extend({
|
||||
mode: attr('string', {
|
||||
defaultValue: 'whitelist',
|
||||
}),
|
||||
mode: attr('string'),
|
||||
paths: attr('array', {
|
||||
defaultValue: function() {
|
||||
return [];
|
|
@ -30,6 +30,10 @@
|
|||
height: 20px;
|
||||
}
|
||||
|
||||
.hs-icon-xlm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.hs-icon-xl {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
.radio-card-selector {
|
||||
display: flex;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
.radio-card {
|
||||
width: 19rem;
|
||||
box-shadow: $box-shadow-low;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin: $spacing-xs $spacing-m;
|
||||
border: $base-border;
|
||||
border-radius: $radius;
|
||||
transition: all ease-in-out $speed;
|
||||
|
||||
input[type='radio'] {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
input[type='radio'] + label {
|
||||
border: 1px solid $grey-light;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
input[type='radio']:checked + label {
|
||||
background: $blue;
|
||||
border: 1px solid $blue;
|
||||
box-shadow: inset 0 0 0 0.15rem $white;
|
||||
}
|
||||
input[type='radio']:focus + label {
|
||||
box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white;
|
||||
}
|
||||
}
|
||||
.radio-card:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.radio-card:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.radio-card-row {
|
||||
display: flex;
|
||||
padding: $spacing-m;
|
||||
}
|
||||
.radio-card-icon {
|
||||
color: $ui-gray-300;
|
||||
}
|
||||
.radio-card-message {
|
||||
margin: $spacing-xxs;
|
||||
}
|
||||
|
||||
.radio-card-message-title {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $size-7;
|
||||
margin-bottom: $spacing-xxs;
|
||||
}
|
||||
.radio-card-message-body {
|
||||
line-height: 1.2;
|
||||
color: $ui-gray-500;
|
||||
font-size: $size-8;
|
||||
}
|
||||
|
||||
.radio-card-radio-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: $ui-gray-050;
|
||||
padding: $spacing-xs;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
&.radio-card {
|
||||
border-color: $blue-500;
|
||||
background: $ui-gray-010;
|
||||
box-shadow: $box-shadow-middle;
|
||||
}
|
||||
.radio-card-icon {
|
||||
color: $black;
|
||||
}
|
||||
.radio-card-radio-row {
|
||||
background: $blue-050;
|
||||
}
|
||||
}
|
|
@ -56,7 +56,7 @@
|
|||
padding-left: $spacing-xxs + $spacing-l;
|
||||
}
|
||||
|
||||
.ember-power-select-options {
|
||||
div > .ember-power-select-options {
|
||||
background: $white;
|
||||
border: $base-border;
|
||||
box-shadow: $box-shadow-middle;
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
@import './components/navigate-input';
|
||||
@import './components/page-header';
|
||||
@import './components/popup-menu';
|
||||
@import './components/radio-card';
|
||||
@import './components/radial-progress';
|
||||
@import './components/raft-join';
|
||||
@import './components/role-item';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* <Icon @glyph="cancel-square-outline" />
|
||||
* ```
|
||||
* @param glyph=null {String} - The name of the SVG to render inline.
|
||||
* @param [size='m'] {String} - The size of the Icon, can be one of 's', 'm', 'l', 'xl', 'xxl'. The default is 'm'.
|
||||
* @param [size='m'] {String} - The size of the Icon, can be one of 's', 'm', 'l', 'xlm', 'xl', 'xxl'. The default is 'm'.
|
||||
*
|
||||
*/
|
||||
import Component from '@ember/component';
|
||||
|
@ -15,7 +15,7 @@ import { computed } from '@ember/object';
|
|||
import { assert } from '@ember/debug';
|
||||
import layout from '../templates/components/icon';
|
||||
|
||||
const SIZES = ['s', 'm', 'l', 'xl', 'xxl'];
|
||||
const SIZES = ['s', 'm', 'l', 'xlm', 'xl', 'xxl'];
|
||||
|
||||
export default Component.extend({
|
||||
tagName: '',
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import Component from '@ember/component';
|
||||
import layout from '../templates/components/search-select-placeholder';
|
||||
|
||||
export default Component.extend({
|
||||
layout,
|
||||
});
|
|
@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
|
|||
import { task } from 'ember-concurrency';
|
||||
import { computed } from '@ember/object';
|
||||
import { singularize } from 'ember-inflector';
|
||||
import layout from '../templates/components/search-select';
|
||||
|
||||
/**
|
||||
* @module SearchSelect
|
||||
|
@ -13,34 +14,25 @@ import { singularize } from 'ember-inflector';
|
|||
* @param id {String} - The name of the form field
|
||||
* @param models {String} - An array of model types to fetch from the API.
|
||||
* @param onChange {Func} - The onchange action for this form field.
|
||||
* @param inputValue {String} - A comma-separated string or an array of strings.
|
||||
* @param inputValue {String | Array} - A comma-separated string or an array of strings.
|
||||
* @param [helpText] {String} - Text to be displayed in the info tooltip for this form field
|
||||
* @param label {String} - Label for this form field
|
||||
* @param fallbackComponent {String} - name of component to be rendered if the API call 403s
|
||||
*
|
||||
* @param options {Array} - *Advanced usage* - `options` can be passed directly from the outside to the
|
||||
* power-select component. If doing this, `models` should not also be passed as that will overwrite the
|
||||
* passed value.
|
||||
* @param search {Func} - *Advanced usage* - Customizes how the power-select component searches for matches -
|
||||
* see the power-select docs for more information.
|
||||
*
|
||||
*/
|
||||
export default Component.extend({
|
||||
layout,
|
||||
'data-test-component': 'search-select',
|
||||
classNames: ['field', 'search-select'],
|
||||
store: service(),
|
||||
|
||||
/*
|
||||
* @public
|
||||
* @param Function
|
||||
*
|
||||
* Function called when any of the inputs change
|
||||
* accepts a single param `value`
|
||||
*
|
||||
*/
|
||||
onChange: () => {},
|
||||
|
||||
/*
|
||||
* @public
|
||||
* @param String | Array
|
||||
* A comma-separated string or an array of strings.
|
||||
* Defaults to an empty array.
|
||||
*
|
||||
*/
|
||||
inputValue: computed(function() {
|
||||
return [];
|
||||
}),
|
||||
|
@ -52,6 +44,16 @@ export default Component.extend({
|
|||
this._super(...arguments);
|
||||
this.set('selectedOptions', this.inputValue || []);
|
||||
},
|
||||
didRender() {
|
||||
this._super(...arguments);
|
||||
let { oldOptions, options, selectedOptions } = this;
|
||||
let hasFormattedInput = typeof selectedOptions.firstObject !== 'string';
|
||||
if (options && !oldOptions && !hasFormattedInput) {
|
||||
// this is the first time they've been set, so we need to format them
|
||||
this.formatOptions(options);
|
||||
}
|
||||
this.set('oldOptions', options);
|
||||
},
|
||||
formatOptions: function(options) {
|
||||
options = options.toArray().map(option => {
|
||||
option.searchText = `${option.name} ${option.id}`;
|
||||
|
@ -68,11 +70,17 @@ export default Component.extend({
|
|||
});
|
||||
this.set('selectedOptions', formattedOptions);
|
||||
if (this.options) {
|
||||
options = this.options.concat(options);
|
||||
options = this.options.concat(options).uniq();
|
||||
}
|
||||
this.set('options', options);
|
||||
},
|
||||
fetchOptions: task(function*() {
|
||||
if (!this.models) {
|
||||
if (this.options) {
|
||||
this.formatOptions(this.options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let modelType of this.models) {
|
||||
if (modelType.includes('identity')) {
|
||||
this.set('shouldRenderName', true);
|
||||
|
@ -128,7 +136,10 @@ export default Component.extend({
|
|||
constructSuggestion(id) {
|
||||
return `Add new ${singularize(this.label)}: ${id}`;
|
||||
},
|
||||
hideCreateOptionOnSameID(id) {
|
||||
hideCreateOptionOnSameID(id, options) {
|
||||
if (options && options.length && options.firstObject.groupName) {
|
||||
return !options.some(group => group.options.findBy('id', id));
|
||||
}
|
||||
let existingOption = this.options && (this.options.findBy('id', id) || this.options.findBy('name', id));
|
||||
return !existingOption;
|
||||
},
|
|
@ -7,7 +7,7 @@
|
|||
helpText=helpText
|
||||
}}
|
||||
{{else}}
|
||||
<label class="title is-4" data-test-field-label>
|
||||
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>
|
||||
{{label}}
|
||||
{{#if helpText}}
|
||||
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
|
||||
|
@ -15,6 +15,7 @@
|
|||
</label>
|
||||
{{#power-select-with-create
|
||||
options=options
|
||||
search=search
|
||||
onchange=(action "selectOption")
|
||||
oncreate=(action "createOption")
|
||||
placeholderComponent=(component "search-select-placeholder")
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'core/components/search-select-placeholder';
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'core/components/search-select';
|
|
@ -18,6 +18,7 @@
|
|||
"ember-composable-helpers": "*",
|
||||
"ember-concurrency": "*",
|
||||
"ember-maybe-in-element": "*",
|
||||
"ember-power-select-with-create": "*",
|
||||
"ember-radio-button": "*",
|
||||
"ember-router-helpers": "*",
|
||||
"ember-svg-jar": "*",
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
## Icon
|
||||
`Icon` components are glyphs used to indicate important information.
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| glyph | <code>String</code> | <code></code> | The name of the SVG to render inline. |
|
||||
| [size] | <code>String</code> | <code>'m'</code> | The size of the Icon, can be one of 's', 'm', 'l', 'xlm', 'xl', 'xxl'. The default is 'm'. |
|
||||
|
||||
**Example**
|
||||
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/search-select.js. To make changes, first edit that file and run "yarn gen-story-md search-select" to re-generate the content.-->
|
||||
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in lib/core/addon/components/search-select.js. To make changes, first edit that file and run "yarn gen-story-md search-select" to re-generate the content.-->
|
||||
|
||||
## SearchSelect
|
||||
The `SearchSelect` is an implementation of the [ember-power-select-with-create](https://github.com/poteto/ember-cli-flash) used for form elements where options come dynamically from the API.
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| id | <code>String</code> | The name of the form field |
|
||||
| models | <code>String</code> | An array of model types to fetch from the API. |
|
||||
| onChange | <code>Func</code> | The onchange action for this form field. |
|
||||
| inputValue | <code>String</code> | A comma-separated string or an array of strings. |
|
||||
| inputValue | <code>String</code> \| <code>Array</code> | A comma-separated string or an array of strings. |
|
||||
| [helpText] | <code>String</code> | Text to be displayed in the info tooltip for this form field |
|
||||
| label | <code>String</code> | Label for this form field |
|
||||
| fallbackComponent | <code>String</code> | name of component to be rendered if the API call 403s |
|
||||
| options | <code>Array</code> | *Advanced usage* - `options` can be passed directly from the outside to the power-select component. If doing this, `models` should not also be passed as that will overwrite the passed value. |
|
||||
| search | <code>Func</code> | *Advanced usage* - Customizes how the power-select component searches for matches - see the power-select docs for more information. |
|
||||
|
||||
**Example**
|
||||
|
||||
|
@ -23,6 +26,6 @@ The `SearchSelect` is an implementation of the [ember-power-select-with-create](
|
|||
**See**
|
||||
|
||||
- [Uses of SearchSelect](https://github.com/hashicorp/vault/search?l=Handlebars&q=SearchSelect+OR+search-select)
|
||||
- [SearchSelect Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/search-select.js)
|
||||
- [SearchSelect Source Code](https://github.com/hashicorp/vault/blob/master/ui/lib/core/addon/components/search-select.js)
|
||||
|
||||
---
|
|
@ -0,0 +1,41 @@
|
|||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { storiesOf } from '@storybook/ember';
|
||||
import notes from './search-select.md';
|
||||
import { withKnobs, text } from '@storybook/addon-knobs';
|
||||
|
||||
const onChange = value => alert(`New value is "${value}"`);
|
||||
const models = ['identity/groups'];
|
||||
|
||||
storiesOf('SearchSelect/', module)
|
||||
.addParameters({ options: { showPanel: true } })
|
||||
.addDecorator(withKnobs({ escapeHTML: false }))
|
||||
.add(
|
||||
`SearchSelect`,
|
||||
() => ({
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Search Select</h5>
|
||||
<SearchSelect
|
||||
@id="groups"
|
||||
@models={{models}}
|
||||
@onChange={{onChange}}
|
||||
@inputValue={{inputValue}}
|
||||
@label={{label}}
|
||||
@fallbackComponent="string-list"
|
||||
@staticOptions={{staticOptions}}/>
|
||||
`,
|
||||
context: {
|
||||
label: text('Label', 'Group IDs'),
|
||||
helpText: text('Help Tooltip Text', 'Group IDs to associate with this entity'),
|
||||
inputValue: [],
|
||||
models: models,
|
||||
onChange: onChange,
|
||||
staticOptions: [
|
||||
{ name: 'my-group', id: '123dsafdsarf' },
|
||||
{ name: 'my-other-group', id: '45ssadd435' },
|
||||
{ name: 'example-1', id: '5678' },
|
||||
{ name: 'group-2', id: 'gro09283' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ notes }
|
||||
);
|
|
@ -1,27 +0,0 @@
|
|||
import Component from '@ember/component';
|
||||
import { set, get, computed } from '@ember/object';
|
||||
|
||||
export default Component.extend({
|
||||
config: null,
|
||||
mounts: null,
|
||||
|
||||
// singleton mounts are not eligible for per-mount-filtering
|
||||
singletonMountTypes: computed(function() {
|
||||
return ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity'];
|
||||
}),
|
||||
|
||||
actions: {
|
||||
addOrRemovePath(path, e) {
|
||||
let config = get(this, 'config') || [];
|
||||
let paths = get(config, 'paths').slice();
|
||||
|
||||
if (e.target.checked) {
|
||||
paths.addObject(path);
|
||||
} else {
|
||||
paths.removeObject(path);
|
||||
}
|
||||
|
||||
set(config, 'paths', paths);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
import Component from '@ember/component';
|
||||
import { set, computed } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { readOnly } from '@ember/object/computed';
|
||||
import { task, timeout } from 'ember-concurrency';
|
||||
|
||||
export default Component.extend({
|
||||
'data-test-component': 'path-filter-config',
|
||||
namespace: service(),
|
||||
store: service(),
|
||||
config: null,
|
||||
namespaces: readOnly('namespace.accessibleNamespaces'),
|
||||
lastOptions: null,
|
||||
autoCompleteOptions: null,
|
||||
namespacesFetched: null,
|
||||
startedWithMode: false,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.setAutoCompleteOptions.perform();
|
||||
if (this.config.mode) {
|
||||
this.set('startedWithMode', true);
|
||||
}
|
||||
},
|
||||
|
||||
fetchMountsForNamespace: task(function*(ns) {
|
||||
let adapter = this.store.adapterFor('application');
|
||||
let secret = [];
|
||||
let auth = [];
|
||||
let mounts = ns
|
||||
? yield adapter.ajax('/v1/sys/internal/ui/mounts', 'GET', { namespace: ns })
|
||||
: yield adapter.ajax('/v1/sys/internal/ui/mounts', 'GET');
|
||||
|
||||
['secret', 'auth'].forEach(key => {
|
||||
for (let [id, info] of Object.entries(mounts.data[key])) {
|
||||
let longId;
|
||||
if (key === 'auth') {
|
||||
longId = ns ? `${ns}/auth/${id}` : `auth/${id}`;
|
||||
} else {
|
||||
longId = ns ? `${ns}/${id}` : id;
|
||||
}
|
||||
info.path = longId;
|
||||
|
||||
// don't add singleton mounts
|
||||
if (!this.singletonMountTypes.includes(info.type)) {
|
||||
(key === 'secret' ? secret : auth).push({
|
||||
id: longId,
|
||||
name: longId,
|
||||
searchText: `${longId} ${info.type} ${info.accessor}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
secret,
|
||||
auth,
|
||||
};
|
||||
}),
|
||||
|
||||
filterOptions(list, term) {
|
||||
let paths = this.config.paths;
|
||||
return list
|
||||
.map(({ groupName, options }) => {
|
||||
let trimmedOptions = options.filter(op => {
|
||||
if (term) {
|
||||
return op.searchText.includes(term) && !paths.includes(op.id);
|
||||
}
|
||||
return !paths.includes(op.id);
|
||||
});
|
||||
return trimmedOptions.length ? { groupName, options: trimmedOptions } : null;
|
||||
})
|
||||
.compact();
|
||||
},
|
||||
|
||||
setAutoCompleteOptions: task(function*(term) {
|
||||
let { namespaces, lastOptions } = this;
|
||||
let namespaceToFetch = namespaces.find(ns => ns === term);
|
||||
let secretList = [];
|
||||
let authList = [];
|
||||
let options = [];
|
||||
if (term) {
|
||||
yield timeout(200);
|
||||
}
|
||||
if (!term || (term && namespaceToFetch)) {
|
||||
// fetch auth and secret methods from sys/internal/ui/mounts for the given namespace
|
||||
let result = yield this.fetchMountsForNamespace.perform(namespaceToFetch);
|
||||
secretList = result.secret;
|
||||
authList = result.auth;
|
||||
}
|
||||
var currentSecrets = lastOptions && lastOptions.findBy('groupName', 'Secret Engines');
|
||||
var currentAuths = lastOptions && lastOptions.findBy('groupName', 'Auth Methods');
|
||||
let formattedNamespaces = namespaces.map(val => {
|
||||
return {
|
||||
id: val,
|
||||
name: val,
|
||||
searchText: val,
|
||||
};
|
||||
});
|
||||
|
||||
options.push({ groupName: 'Namespaces', options: formattedNamespaces });
|
||||
let secretOptions = currentSecrets ? [...currentSecrets.options, ...secretList] : secretList;
|
||||
|
||||
options.push({ groupName: 'Secret Engines', options: secretOptions.uniqBy('id') });
|
||||
let authOptions = currentAuths ? [...currentAuths.options, ...authList] : authList;
|
||||
|
||||
options.push({ groupName: 'Auth Methods', options: authOptions.uniqBy('id') });
|
||||
let filtered = term ? this.filterOptions(options, term) : this.filterOptions(options);
|
||||
if (!term) {
|
||||
this.set('autoCompleteOptions', filtered);
|
||||
}
|
||||
this.set('lastOptions', filtered);
|
||||
return filtered;
|
||||
}),
|
||||
|
||||
// singleton mounts are not eligible for per-mount-filtering
|
||||
singletonMountTypes: computed(function() {
|
||||
return ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity', 'ns_token'];
|
||||
}),
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
},
|
||||
|
||||
actions: {
|
||||
pathsChanged(paths) {
|
||||
// set paths on the model
|
||||
set(this.config, 'paths', paths);
|
||||
if (paths.length) {
|
||||
// remove the selected item from the default list of options
|
||||
let filtered = this.filterOptions(this.autoCompleteOptions);
|
||||
this.set('autoCompleteOptions', filtered);
|
||||
} else {
|
||||
// if there's no paths, we need to re-fetch like on init
|
||||
this.setAutoCompleteOptions.perform();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
|
@ -2,22 +2,23 @@ import { isPresent } from '@ember/utils';
|
|||
import { alias } from '@ember/object/computed';
|
||||
import { inject as service } from '@ember/service';
|
||||
import Controller from '@ember/controller';
|
||||
import { copy } from 'ember-copy';
|
||||
import { resolve } from 'rsvp';
|
||||
|
||||
const DEFAULTS = {
|
||||
token: null,
|
||||
id: null,
|
||||
loading: false,
|
||||
errors: [],
|
||||
showFilterConfig: false,
|
||||
primary_api_addr: null,
|
||||
primary_cluster_addr: null,
|
||||
filterConfig: {
|
||||
mode: 'whitelist',
|
||||
mode: null,
|
||||
paths: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default Controller.extend(DEFAULTS, {
|
||||
export default Controller.extend(copy(DEFAULTS, true), {
|
||||
store: service(),
|
||||
rm: service('replication-mode'),
|
||||
replicationMode: alias('rm.mode'),
|
||||
|
@ -34,12 +35,16 @@ export default Controller.extend(DEFAULTS, {
|
|||
const config = this.get('filterConfig');
|
||||
const id = this.get('id');
|
||||
config.id = id;
|
||||
const configRecord = this.get('store').createRecord('mount-filter-config', config);
|
||||
// if there is no mode, then they don't want to filter, so we don't save a filter config
|
||||
if (!config.mode) {
|
||||
return resolve();
|
||||
}
|
||||
const configRecord = this.get('store').createRecord('path-filter-config', config);
|
||||
return configRecord.save().catch(e => this.submitError(e));
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.setProperties(DEFAULTS);
|
||||
this.setProperties(copy(DEFAULTS, true));
|
||||
},
|
||||
|
||||
submitSuccess(resp, action) {
|
||||
|
@ -66,14 +71,9 @@ export default Controller.extend(DEFAULTS, {
|
|||
|
||||
submitHandler(action, clusterMode, data, event) {
|
||||
const replicationMode = this.get('replicationMode');
|
||||
let saveFilterConfig;
|
||||
if (event && event.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (data && isPresent(data.saveFilterConfig)) {
|
||||
saveFilterConfig = data.saveFilterConfig;
|
||||
delete data.saveFilterConfig;
|
||||
}
|
||||
this.setProperties({
|
||||
loading: true,
|
||||
errors: [],
|
||||
|
@ -93,13 +93,9 @@ export default Controller.extend(DEFAULTS, {
|
|||
.replicationAction(action, replicationMode, clusterMode, data)
|
||||
.then(
|
||||
resp => {
|
||||
if (saveFilterConfig) {
|
||||
return this.saveFilterConfig().then(() => {
|
||||
return this.submitSuccess(resp, action, clusterMode);
|
||||
});
|
||||
} else {
|
||||
return this.submitSuccess(resp, action, clusterMode);
|
||||
}
|
||||
},
|
||||
(...args) => this.submitError(...args)
|
||||
);
|
||||
|
|
|
@ -2,11 +2,6 @@ import { alias } from '@ember/object/computed';
|
|||
import { inject as service } from '@ember/service';
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
const CONFIG_DEFAULTS = {
|
||||
mode: 'whitelist',
|
||||
paths: [],
|
||||
};
|
||||
|
||||
export default Controller.extend({
|
||||
flashMessages: service(),
|
||||
rm: service('replication-mode'),
|
||||
|
@ -14,13 +9,18 @@ export default Controller.extend({
|
|||
actions: {
|
||||
resetConfig(config) {
|
||||
if (config.get('isNew')) {
|
||||
config.setProperties(CONFIG_DEFAULTS);
|
||||
config.setProperties({
|
||||
mode: null,
|
||||
paths: [],
|
||||
});
|
||||
} else {
|
||||
config.rollbackAttributes();
|
||||
}
|
||||
},
|
||||
|
||||
saveConfig(config, isDelete) {
|
||||
saveConfig(config) {
|
||||
// if the mode is null, we want no filtering, so we should delete any existing config
|
||||
let isDelete = config.mode === null;
|
||||
const flash = this.get('flashMessages');
|
||||
const id = config.id;
|
||||
const redirectArgs = isDelete
|
||||
|
|
|
@ -1,26 +1,17 @@
|
|||
import { hash } from 'rsvp';
|
||||
import Base from '../../replication-base';
|
||||
|
||||
export default Base.extend({
|
||||
model() {
|
||||
return hash({
|
||||
cluster: this.modelFor('mode.secondaries'),
|
||||
mounts: this.fetchMounts(),
|
||||
});
|
||||
return this.modelFor('mode.secondaries');
|
||||
},
|
||||
|
||||
redirect(model) {
|
||||
const replicationMode = this.paramsFor('mode').replication_mode;
|
||||
if (!model.cluster.get(`${replicationMode}.isPrimary`) || !model.cluster.get('canAddSecondary')) {
|
||||
if (!model.get(`${replicationMode}.isPrimary`) || !model.get('canAddSecondary')) {
|
||||
return this.transitionTo('mode', replicationMode);
|
||||
}
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
controller.set('model', model.cluster);
|
||||
controller.set('mounts', model.mounts);
|
||||
},
|
||||
|
||||
resetController(controller) {
|
||||
controller.reset();
|
||||
},
|
||||
|
|
|
@ -10,21 +10,23 @@ export default Base.extend({
|
|||
findOrCreate(id) {
|
||||
const flash = this.get('flashMessages');
|
||||
return this.store
|
||||
.findRecord('mount-filter-config', id)
|
||||
.findRecord('path-filter-config', id)
|
||||
.then(() => {
|
||||
// if we find a record, transition to the edit view
|
||||
return this.transitionTo('mode.secondaries.config-edit', id)
|
||||
.followRedirects()
|
||||
.then(() => {
|
||||
flash.info(
|
||||
`${id} already had a mount filter config, so we loaded the config edit screen for you.`
|
||||
`${id} already had a path filter config, so we loaded the config edit screen for you.`
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.httpStatus === 404) {
|
||||
return this.store.createRecord('mount-filter-config', {
|
||||
return this.store.createRecord('path-filter-config', {
|
||||
id,
|
||||
mode: null,
|
||||
paths: [],
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
|
@ -49,7 +51,6 @@ export default Base.extend({
|
|||
return hash({
|
||||
cluster: this.modelFor('mode'),
|
||||
config: this.findOrCreate(params.secondary_id),
|
||||
mounts: this.fetchMounts(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -7,8 +7,7 @@ export default Base.extend({
|
|||
model(params) {
|
||||
return hash({
|
||||
cluster: this.modelFor('mode.secondaries'),
|
||||
config: this.store.findRecord('mount-filter-config', params.secondary_id),
|
||||
mounts: this.fetchMounts(),
|
||||
config: this.store.findRecord('path-filter-config', params.secondary_id),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export default Base.extend({
|
|||
const id = params.secondary_id;
|
||||
return hash({
|
||||
cluster: this.modelFor('application'),
|
||||
config: this.store.findRecord('mount-filter-config', id).catch(e => {
|
||||
config: this.store.findRecord('path-filter-config', id).catch(e => {
|
||||
if (e.httpStatus === 404) {
|
||||
// return an empty obj to let them nav to create
|
||||
return resolve({ id });
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { alias } from '@ember/object/computed';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { hash, resolve } from 'rsvp';
|
||||
import Route from '@ember/routing/route';
|
||||
import UnloadModelRouteMixin from 'vault/mixins/unload-model-route';
|
||||
|
||||
|
@ -9,14 +8,5 @@ export default Route.extend(UnloadModelRouteMixin, {
|
|||
version: service(),
|
||||
rm: service('replication-mode'),
|
||||
modelPath: 'model.config',
|
||||
fetchMounts() {
|
||||
return hash({
|
||||
mounts: this.store.findAll('secret-engine'),
|
||||
auth: this.store.findAll('auth-method'),
|
||||
}).then(({ mounts, auth }) => {
|
||||
return resolve(mounts.toArray().concat(auth.toArray()));
|
||||
});
|
||||
},
|
||||
|
||||
replicationMode: alias('rm.mode'),
|
||||
});
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
<label for="filter-mode" class="is-label">
|
||||
Mount filter mode
|
||||
</label>
|
||||
<div class="field is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="filter-mode"
|
||||
onchange={{action (mut config.mode) value="target.value"}}
|
||||
data-test-replication-filter-mount-mode=true
|
||||
>
|
||||
{{#each (array "whitelist" "blacklist") as |mode|}}
|
||||
<option selected={{eq config.mode mode}} value="{{mode}}">
|
||||
{{capitalize mode}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="help has-text-grey">
|
||||
{{#if (eq config.mode "blacklist")}}
|
||||
Selected mounts will be excluded when replicating to the secondary <code>{{or id config.id}}</code>.
|
||||
{{else}}
|
||||
<em>Only</em> the selected mounts will be replicated to the secondary <code>{{or id config.id}}</code>.
|
||||
{{/if}}
|
||||
</p>
|
||||
</div>
|
||||
<label class="is-label">
|
||||
Filtered Mounts
|
||||
</label>
|
||||
<div class="columns is-mobile is-gapless is-marginless table">
|
||||
<div class="column is-narrow thead">
|
||||
<div class="th"></div>
|
||||
</div>
|
||||
<div class="column is-5 thead">
|
||||
<div class="th">
|
||||
Mount Path
|
||||
</div>
|
||||
</div>
|
||||
<div class="column thead">
|
||||
<div class="th">
|
||||
Mount Type
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#each mounts as |mount|}}
|
||||
{{#unless (or mount.local (contains mount.type singletonMountTypes))}}
|
||||
{{!-- auth mounts have apiPath, secret mounts use path --}}
|
||||
{{#with (or mount.apiPath mount.path) as |path| }}
|
||||
<label for="filter-{{mount.accessor}}" class="columns is-mobile is-gapless is-marginless table">
|
||||
<div class="column is-narrow td">
|
||||
<div class="td is-borderless">
|
||||
<div class="field">
|
||||
<div class="b-checkbox no-label">
|
||||
<input
|
||||
id="filter-{{mount.accessor}}"
|
||||
type="checkbox"
|
||||
class="styled"
|
||||
checked={{contains config.paths path}}
|
||||
onChange={{action 'addOrRemovePath' path}}
|
||||
data-test-mount-filter="{{mount.type}}"
|
||||
/>
|
||||
<label for="filter-{{mount.accessor}}" class="is-label"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<code class="column is-5 td">
|
||||
<div class="td is-borderless" data-test-mount-filter-path-for-type="{{mount.type}}">
|
||||
{{path}}
|
||||
</div>
|
||||
</code>
|
||||
<div class="column td">
|
||||
<div class="td is-borderless">
|
||||
{{mount.type}}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{{/with}}
|
||||
{{/unless}}
|
||||
{{/each}}
|
|
@ -0,0 +1,126 @@
|
|||
<h4 class="title is-5">
|
||||
Filtered paths
|
||||
</h4>
|
||||
<div class="radio-card-selector">
|
||||
<label
|
||||
for="no-filtering"
|
||||
class="radio-card
|
||||
{{if (not config.mode) " is-selected"}}"
|
||||
>
|
||||
<div class="radio-card-row">
|
||||
<Icon
|
||||
@glyph="file-fill"
|
||||
@size="xlm"
|
||||
class="radio-card-icon"
|
||||
/>
|
||||
<div class="radio-card-message">
|
||||
<h5 class="radio-card-message-title">
|
||||
Include everything
|
||||
</h5>
|
||||
<p class="radio-card-message-body">
|
||||
All namespaces and mounts in this cluster will be replicated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-card-radio-row">
|
||||
<RadioButton
|
||||
@value={{null}}
|
||||
@radioClass="radio"
|
||||
@groupValue={{config.mode}}
|
||||
@changed={{queue
|
||||
(action (mut config.mode))
|
||||
}}
|
||||
@name="config-mode"
|
||||
@radioId="no-filtering"
|
||||
/>
|
||||
<label for="no-filtering"></label>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
for="allow"
|
||||
class="radio-card
|
||||
{{if (eq config.mode "allow") " is-selected"}}"
|
||||
>
|
||||
<div class="radio-card-row">
|
||||
<Icon
|
||||
@glyph="file-success"
|
||||
@size="xlm"
|
||||
class="radio-card-icon"
|
||||
/>
|
||||
<div class="radio-card-message">
|
||||
<h5 class="radio-card-message-title">
|
||||
Allow
|
||||
</h5>
|
||||
<p class="radio-card-message-body">
|
||||
Only include the selected namespaces and mounts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-card-radio-row">
|
||||
<RadioButton
|
||||
@value="allow"
|
||||
@radioClass="radio"
|
||||
@groupValue={{config.mode}}
|
||||
@changed={{queue
|
||||
(action (mut config.mode))
|
||||
}}
|
||||
@name="config-mode"
|
||||
@radioId="allow"
|
||||
/>
|
||||
<label for="allow"></label>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
for="deny"
|
||||
class="radio-card
|
||||
{{if (eq config.mode "deny") " is-selected"}}"
|
||||
>
|
||||
<div class="radio-card-row">
|
||||
<Icon
|
||||
@glyph="file-error"
|
||||
@size="xlm"
|
||||
class="radio-card-icon"
|
||||
/>
|
||||
<div class="radio-card-message">
|
||||
<h5 class="radio-card-message-title">
|
||||
Deny
|
||||
</h5>
|
||||
<p class="radio-card-message-body">
|
||||
Do not include the selected namespaces and mounts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-card-radio-row">
|
||||
<RadioButton
|
||||
@value="deny"
|
||||
@radioClass="radio"
|
||||
@groupValue={{config.mode}}
|
||||
@changed={{queue
|
||||
(action (mut config.mode))
|
||||
}}
|
||||
@name="config-mode"
|
||||
@radioId="deny"
|
||||
/>
|
||||
<label for="deny"></label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{{#if (or (eq config.mode "allow") (eq config.mode "deny"))}}
|
||||
<SearchSelect
|
||||
@id="paths"
|
||||
@labelClass="title is-7"
|
||||
@onChange={{action "pathsChanged"}}
|
||||
@inputValue={{config.paths}}
|
||||
@label="Paths in this {{config.mode}}"
|
||||
@options={{this.autoCompleteOptions}}
|
||||
@search={{perform this.setAutoCompleteOptions}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (and (not config.mode) this.startedWithMode)}}
|
||||
<AlertInline
|
||||
data-test-remove-warning
|
||||
@type="warning"
|
||||
@message="Saving with 'Include everything' will remove any existing filter configuration"
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<form {{action "onSubmit" "secondary-token" "primary" (hash ttl=ttl id=id saveFilterConfig=showFilterConfig) on="submit"}}>
|
||||
<form {{action "onSubmit" "secondary-token" "primary" (hash ttl=ttl id=id) on="submit"}}>
|
||||
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||
<h4 class="title is-5">
|
||||
Generate a secondary token
|
||||
|
@ -51,24 +51,11 @@
|
|||
</p>
|
||||
</div>
|
||||
{{#if (eq replicationMode "performance")}}
|
||||
<div class="field">
|
||||
{{toggle-button
|
||||
toggleTarget=this
|
||||
toggleAttr='showFilterConfig'
|
||||
closedLabel='Configure performance mount filtering'
|
||||
openLabel='Hide performance mount filtering config'
|
||||
data-test-replication-secondary-token-options=true
|
||||
}}
|
||||
{{#if showFilterConfig}}
|
||||
<div class="box">
|
||||
{{mount-filter-config-list
|
||||
config=filterConfig
|
||||
mounts=mounts
|
||||
id=id
|
||||
}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<PathFilterConfigList
|
||||
@paths={{paths}}
|
||||
@config={{filterConfig}}
|
||||
@id={{id}}
|
||||
/>
|
||||
{{/if}}
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||
<h4 class="title is-5 is-marginless">
|
||||
Create a mount filter config for <code>{{model.config.id}}</code>
|
||||
Create a path filter config for <code>{{model.config.id}}</code>
|
||||
</h4>
|
||||
</div>
|
||||
<form {{action "saveConfig" model.config on="submit"}}>
|
||||
{{mount-filter-config-list
|
||||
mounts=model.mounts
|
||||
{{path-filter-config-list
|
||||
paths=model.paths
|
||||
config=model.config
|
||||
}}
|
||||
<div class="field is-grouped box is-fullwidth is-shadowless">
|
||||
|
|
|
@ -1,31 +1,19 @@
|
|||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@confirmMessage="This will affect which data gets replicated to this secondary."
|
||||
@onConfirmAction={{action "saveConfig" model.config true}}
|
||||
data-test-delete-mount-config="true"
|
||||
>
|
||||
Delete config
|
||||
</ConfirmAction>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
|
||||
<Toolbar/>
|
||||
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||
<h4 class="title is-5 is-marginless">
|
||||
Edit mount filter config for <code>{{model.config.id}}</code>
|
||||
Edit path filter config for <code>{{model.config.id}}</code>
|
||||
</h4>
|
||||
</div>
|
||||
<form {{action "saveConfig" model.config on="submit"}}>
|
||||
{{mount-filter-config-list
|
||||
mounts=model.mounts
|
||||
config=model.config
|
||||
}}
|
||||
<PathFilterConfigList
|
||||
@paths={{model.paths}}
|
||||
@config={{model.config}}
|
||||
/>
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" >
|
||||
Save
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20,14 L20,20.5714286 C20,21.3571429 19.2857143,22 18.5,22 L4.5,22 C3.71428571,22 3,21.2857143 3,20.5 L3,3.5 C3,2.71428571 3.71428571,2 4.5,2 L10,2 L10,3.5 L4.5,3.5 L4.5,20.5 L18.5,20.5 L18.5,14 L20,14 Z M7,18.5 L16,18.5 L16,17 L7,17 L7,18.5 Z M7,15.5 L16,15.5 L16,14 L7,14 L7,15.5 Z M7,12.5 L11,12.5 L11,11 L7,11 L7,12.5 Z M7,9.5 L10,9.5 L10,8 L7,8 L7,9.5 Z M20,1 C21.6568542,1 23,2.34314575 23,4 L23,9 C23,10.6568542 21.6568542,12 20,12 L15,12 C13.3431458,12 12,10.6568542 12,9 L12,4 C12,2.34314575 13.3431458,1 15,1 L20,1 Z M19.9425,3 L17.5,5.4425 L15.0575,3 L14,4.0575 L16.4425,6.5 L14,8.9425 L15.0575,10 L17.5,7.5575 L19.9425,10 L21,8.9425 L18.5575,6.5 L21,4.0575 L19.9425,3 Z" fill="currentColor" />
|
||||
</svg>
|
After Width: | Height: | Size: 785 B |
|
@ -0,0 +1,3 @@
|
|||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20,14 L20,20.5714286 C20,21.3571429 19.2857143,22 18.5,22 L4.5,22 C3.71428571,22 3,21.2857143 3,20.5 L3,3.5 C3,2.71428571 3.71428571,2 4.5,2 L11,2 L10,3.5 L4.5,3.5 L4.5,20.5 L18.5,20.5 L18.5,14 L20,14 Z M7,18.5 L16,18.5 L16,17 L7,17 L7,18.5 Z M7,15.5 L16,15.5 L16,14 L7,14 L7,15.5 Z M7,12.5 L12,12.5 L12,11 L7,11 L7,12.5 Z M7,9.5 L10,9.5 L10,8 L7,8 L7,9.5 Z M17.5,12 C20.5375661,12 23,9.53756612 23,6.5 C23,3.46243388 20.5375661,1 17.5,1 C14.4624339,1 12,3.46243388 12,6.5 C12,9.53756612 14.4624339,12 17.5,12 Z M14,6.66152019 L15.0254545,5.6567696 L16.9090909,7.49524941 L20.4745455,4 L21.5,5.01187648 L16.9090909,9.51187648 L14,6.66152019 Z" fill="currentColor" />
|
||||
</svg>
|
After Width: | Height: | Size: 747 B |
|
@ -1,34 +0,0 @@
|
|||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { storiesOf } from '@storybook/ember';
|
||||
import notes from './search-select.md';
|
||||
import { withKnobs, text, select } from '@storybook/addon-knobs';
|
||||
|
||||
const onChange = (value) => alert(`New value is "${value}"`);
|
||||
const models = ["identity/groups"];
|
||||
|
||||
storiesOf('SearchSelect/', module)
|
||||
.addParameters({ options: { showPanel: true } })
|
||||
.addDecorator(withKnobs({ escapeHTML: false }))
|
||||
.add(`SearchSelect`, () => ({
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Search Select</h5>
|
||||
<SearchSelect
|
||||
@id="groups"
|
||||
@models={{models}}
|
||||
@onChange={{onChange}}
|
||||
@inputValue={{inputValue}}
|
||||
@label={{label}}
|
||||
@fallbackComponent="string-list"
|
||||
@staticOptions={{staticOptions}}/>
|
||||
`,
|
||||
context: {
|
||||
label: text("Label", "Group IDs"),
|
||||
helpText: text("Help Tooltip Text", "Group IDs to associate with this entity"),
|
||||
inputValue: [],
|
||||
models: models,
|
||||
onChange: onChange,
|
||||
staticOptions: [{ name: "my-group", id: "123dsafdsarf" }, { name: "my-other-group", id: "45ssadd435" }, { name: "example-1", id: "5678" }, { name: "group-2", id: "gro09283" }],
|
||||
},
|
||||
}),
|
||||
{ notes }
|
||||
);
|
|
@ -1,38 +0,0 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, findAll, fillIn, blur } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
|
||||
const resolver = engineResolverFor('replication');
|
||||
|
||||
module('Integration | Component | mount filter config list', function(hooks) {
|
||||
setupRenderingTest(hooks, { resolver });
|
||||
|
||||
test('it renders', async function(assert) {
|
||||
this.set('config', { mode: 'whitelist', paths: [] });
|
||||
this.set('mounts', [{ path: 'userpass/', type: 'userpass', accessor: 'userpass' }]);
|
||||
await render(hbs`{{mount-filter-config-list config=config mounts=mounts}}`);
|
||||
|
||||
assert.equal(findAll('#filter-userpass').length, 1);
|
||||
});
|
||||
|
||||
test('it sets config.paths', async function(assert) {
|
||||
this.set('config', { mode: 'whitelist', paths: [] });
|
||||
this.set('mounts', [{ path: 'userpass/', type: 'userpass', accessor: 'userpass' }]);
|
||||
await render(hbs`{{mount-filter-config-list config=config mounts=mounts}}`);
|
||||
|
||||
await click('#filter-userpass');
|
||||
assert.ok(this.get('config.paths').includes('userpass/'), 'adds to paths');
|
||||
|
||||
await click('#filter-userpass');
|
||||
assert.equal(this.get('config.paths').length, 0, 'removes from paths');
|
||||
});
|
||||
|
||||
test('it sets config.mode', async function(assert) {
|
||||
this.set('config', { mode: 'whitelist', paths: [] });
|
||||
await render(hbs`{{mount-filter-config-list config=config}}`);
|
||||
await fillIn('#filter-mode', 'blacklist');
|
||||
await blur('#filter-mode');
|
||||
assert.equal(this.get('config.mode'), 'blacklist');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,116 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { typeInSearch, clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
import { Promise } from 'rsvp';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
|
||||
const searchSelect = create(ss);
|
||||
|
||||
const resolver = engineResolverFor('replication');
|
||||
|
||||
const MOUNTS_RESPONSE = {
|
||||
data: {
|
||||
secret: {},
|
||||
auth: {
|
||||
'userpass/': { type: 'userpass', accessor: 'userpass' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const NAMESPACE_MOUNTS_RESPONSE = {
|
||||
data: {
|
||||
secret: {
|
||||
'namespace-kv/': { type: 'kv', accessor: 'kv' },
|
||||
},
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
|
||||
module('Integration | Component | path filter config list', function(hooks) {
|
||||
setupRenderingTest(hooks, { resolver });
|
||||
hooks.beforeEach(function() {
|
||||
let ajaxStub = sinon.stub().usingPromise(Promise);
|
||||
ajaxStub.withArgs('/v1/sys/internal/ui/mounts', 'GET').resolves(MOUNTS_RESPONSE);
|
||||
ajaxStub
|
||||
.withArgs('/v1/sys/internal/ui/mounts', 'GET', { namespace: 'ns1' })
|
||||
.resolves(NAMESPACE_MOUNTS_RESPONSE);
|
||||
this.set('ajaxStub', ajaxStub);
|
||||
const namespaceServiceStub = Service.extend({
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.set('accessibleNamespaces', ['ns1']);
|
||||
},
|
||||
});
|
||||
|
||||
const storeServiceStub = Service.extend({
|
||||
adapterFor() {
|
||||
return {
|
||||
ajax: ajaxStub,
|
||||
};
|
||||
},
|
||||
});
|
||||
this.owner.register('service:namespace', namespaceServiceStub);
|
||||
this.owner.register('service:store', storeServiceStub);
|
||||
});
|
||||
|
||||
test('it renders', async function(assert) {
|
||||
this.set('config', { mode: null, paths: [] });
|
||||
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
|
||||
|
||||
assert.dom('[data-test-component=path-filter-config]').exists();
|
||||
});
|
||||
|
||||
test('it sets config.paths', async function(assert) {
|
||||
this.set('config', { mode: 'allow', paths: [] });
|
||||
this.set('paths', []);
|
||||
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
|
||||
|
||||
await clickTrigger();
|
||||
await typeInSearch('auth');
|
||||
await searchSelect.options.objectAt(1).click();
|
||||
assert.ok(this.config.paths.includes('auth/userpass/'), 'adds to paths');
|
||||
|
||||
await clickTrigger();
|
||||
await assert.equal(searchSelect.options.length, 1, 'has one option left');
|
||||
|
||||
await searchSelect.deleteButtons.objectAt(0).click();
|
||||
assert.equal(this.config.paths.length, 0, 'removes from paths');
|
||||
await clickTrigger();
|
||||
await assert.equal(searchSelect.options.length, 2, 'has both options');
|
||||
});
|
||||
|
||||
test('it sets config.mode', async function(assert) {
|
||||
this.set('config', { mode: 'allow', paths: [] });
|
||||
await render(hbs`<PathFilterConfigList @config={{this.config}} />`);
|
||||
await click('#deny');
|
||||
assert.equal(this.config.mode, 'deny');
|
||||
await click('#no-filtering');
|
||||
assert.equal(this.config.mode, null);
|
||||
});
|
||||
|
||||
test('it shows a warning when going from a mode to allow all', async function(assert) {
|
||||
this.set('config', { mode: 'allow', paths: [] });
|
||||
await render(hbs`<PathFilterConfigList @config={{this.config}} />`);
|
||||
await click('#no-filtering');
|
||||
assert.dom('[data-test-remove-warning]').exists('shows removal warning');
|
||||
});
|
||||
|
||||
test('it fetches mounts from a namespace when namespace name is entered', async function(assert) {
|
||||
this.set('config', { mode: 'allow', paths: [] });
|
||||
this.set('paths', []);
|
||||
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
|
||||
|
||||
await clickTrigger();
|
||||
assert.equal(searchSelect.options.length, 2, 'shows userpass and namespace as an option');
|
||||
// type the namespace to trigger an ajax request
|
||||
await typeInSearch('ns1');
|
||||
assert.equal(searchSelect.options.length, 2, 'has ns and ns mount in the list');
|
||||
await searchSelect.options.objectAt(1).click();
|
||||
assert.ok(this.config.paths.includes('ns1/namespace-kv/'), 'adds namespace mount to paths');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue