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({
|
export default ApplicationAdapter.extend({
|
||||||
url(id) {
|
url(id) {
|
||||||
return `${this.buildURL()}/replication/performance/primary/mount-filter/${id}`;
|
return `${this.buildURL()}/replication/performance/primary/paths-filter/${id}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
findRecord(store, type, id) {
|
findRecord(store, type, id) {
|
|
@ -2,9 +2,7 @@ import DS from 'ember-data';
|
||||||
const { attr } = DS;
|
const { attr } = DS;
|
||||||
|
|
||||||
export default DS.Model.extend({
|
export default DS.Model.extend({
|
||||||
mode: attr('string', {
|
mode: attr('string'),
|
||||||
defaultValue: 'whitelist',
|
|
||||||
}),
|
|
||||||
paths: attr('array', {
|
paths: attr('array', {
|
||||||
defaultValue: function() {
|
defaultValue: function() {
|
||||||
return [];
|
return [];
|
|
@ -30,6 +30,10 @@
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hs-icon-xlm {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
.hs-icon-xl {
|
.hs-icon-xl {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 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;
|
padding-left: $spacing-xxs + $spacing-l;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ember-power-select-options {
|
div > .ember-power-select-options {
|
||||||
background: $white;
|
background: $white;
|
||||||
border: $base-border;
|
border: $base-border;
|
||||||
box-shadow: $box-shadow-middle;
|
box-shadow: $box-shadow-middle;
|
||||||
|
|
|
@ -72,6 +72,7 @@
|
||||||
@import './components/navigate-input';
|
@import './components/navigate-input';
|
||||||
@import './components/page-header';
|
@import './components/page-header';
|
||||||
@import './components/popup-menu';
|
@import './components/popup-menu';
|
||||||
|
@import './components/radio-card';
|
||||||
@import './components/radial-progress';
|
@import './components/radial-progress';
|
||||||
@import './components/raft-join';
|
@import './components/raft-join';
|
||||||
@import './components/role-item';
|
@import './components/role-item';
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
* <Icon @glyph="cancel-square-outline" />
|
* <Icon @glyph="cancel-square-outline" />
|
||||||
* ```
|
* ```
|
||||||
* @param glyph=null {String} - The name of the SVG to render inline.
|
* @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';
|
import Component from '@ember/component';
|
||||||
|
@ -15,7 +15,7 @@ import { computed } from '@ember/object';
|
||||||
import { assert } from '@ember/debug';
|
import { assert } from '@ember/debug';
|
||||||
import layout from '../templates/components/icon';
|
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({
|
export default Component.extend({
|
||||||
tagName: '',
|
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 { task } from 'ember-concurrency';
|
||||||
import { computed } from '@ember/object';
|
import { computed } from '@ember/object';
|
||||||
import { singularize } from 'ember-inflector';
|
import { singularize } from 'ember-inflector';
|
||||||
|
import layout from '../templates/components/search-select';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module SearchSelect
|
* @module SearchSelect
|
||||||
|
@ -13,34 +14,25 @@ import { singularize } from 'ember-inflector';
|
||||||
* @param id {String} - The name of the form field
|
* @param id {String} - The name of the form field
|
||||||
* @param models {String} - An array of model types to fetch from the API.
|
* @param models {String} - An array of model types to fetch from the API.
|
||||||
* @param onChange {Func} - The onchange action for this form field.
|
* @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 [helpText] {String} - Text to be displayed in the info tooltip for this form field
|
||||||
* @param label {String} - Label 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 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({
|
export default Component.extend({
|
||||||
|
layout,
|
||||||
'data-test-component': 'search-select',
|
'data-test-component': 'search-select',
|
||||||
classNames: ['field', 'search-select'],
|
classNames: ['field', 'search-select'],
|
||||||
store: service(),
|
store: service(),
|
||||||
|
|
||||||
/*
|
|
||||||
* @public
|
|
||||||
* @param Function
|
|
||||||
*
|
|
||||||
* Function called when any of the inputs change
|
|
||||||
* accepts a single param `value`
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
|
|
||||||
/*
|
|
||||||
* @public
|
|
||||||
* @param String | Array
|
|
||||||
* A comma-separated string or an array of strings.
|
|
||||||
* Defaults to an empty array.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
inputValue: computed(function() {
|
inputValue: computed(function() {
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
|
@ -52,6 +44,16 @@ export default Component.extend({
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.set('selectedOptions', this.inputValue || []);
|
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) {
|
formatOptions: function(options) {
|
||||||
options = options.toArray().map(option => {
|
options = options.toArray().map(option => {
|
||||||
option.searchText = `${option.name} ${option.id}`;
|
option.searchText = `${option.name} ${option.id}`;
|
||||||
|
@ -68,11 +70,17 @@ export default Component.extend({
|
||||||
});
|
});
|
||||||
this.set('selectedOptions', formattedOptions);
|
this.set('selectedOptions', formattedOptions);
|
||||||
if (this.options) {
|
if (this.options) {
|
||||||
options = this.options.concat(options);
|
options = this.options.concat(options).uniq();
|
||||||
}
|
}
|
||||||
this.set('options', options);
|
this.set('options', options);
|
||||||
},
|
},
|
||||||
fetchOptions: task(function*() {
|
fetchOptions: task(function*() {
|
||||||
|
if (!this.models) {
|
||||||
|
if (this.options) {
|
||||||
|
this.formatOptions(this.options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let modelType of this.models) {
|
for (let modelType of this.models) {
|
||||||
if (modelType.includes('identity')) {
|
if (modelType.includes('identity')) {
|
||||||
this.set('shouldRenderName', true);
|
this.set('shouldRenderName', true);
|
||||||
|
@ -128,7 +136,10 @@ export default Component.extend({
|
||||||
constructSuggestion(id) {
|
constructSuggestion(id) {
|
||||||
return `Add new ${singularize(this.label)}: ${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));
|
let existingOption = this.options && (this.options.findBy('id', id) || this.options.findBy('name', id));
|
||||||
return !existingOption;
|
return !existingOption;
|
||||||
},
|
},
|
|
@ -7,7 +7,7 @@
|
||||||
helpText=helpText
|
helpText=helpText
|
||||||
}}
|
}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<label class="title is-4" data-test-field-label>
|
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>
|
||||||
{{label}}
|
{{label}}
|
||||||
{{#if helpText}}
|
{{#if helpText}}
|
||||||
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
|
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
|
||||||
|
@ -15,6 +15,7 @@
|
||||||
</label>
|
</label>
|
||||||
{{#power-select-with-create
|
{{#power-select-with-create
|
||||||
options=options
|
options=options
|
||||||
|
search=search
|
||||||
onchange=(action "selectOption")
|
onchange=(action "selectOption")
|
||||||
oncreate=(action "createOption")
|
oncreate=(action "createOption")
|
||||||
placeholderComponent=(component "search-select-placeholder")
|
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-composable-helpers": "*",
|
||||||
"ember-concurrency": "*",
|
"ember-concurrency": "*",
|
||||||
"ember-maybe-in-element": "*",
|
"ember-maybe-in-element": "*",
|
||||||
|
"ember-power-select-with-create": "*",
|
||||||
"ember-radio-button": "*",
|
"ember-radio-button": "*",
|
||||||
"ember-router-helpers": "*",
|
"ember-router-helpers": "*",
|
||||||
"ember-svg-jar": "*",
|
"ember-svg-jar": "*",
|
||||||
|
|
|
@ -3,10 +3,12 @@
|
||||||
## Icon
|
## Icon
|
||||||
`Icon` components are glyphs used to indicate important information.
|
`Icon` components are glyphs used to indicate important information.
|
||||||
|
|
||||||
|
**Params**
|
||||||
|
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| glyph | <code>String</code> | <code></code> | The name of the SVG to render inline. |
|
| 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**
|
**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
|
## 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.
|
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 |
|
| Param | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| id | <code>String</code> | The name of the form field |
|
| id | <code>String</code> | The name of the form field |
|
||||||
| models | <code>String</code> | An array of model types to fetch from the API. |
|
| 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. |
|
| 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 |
|
| [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 |
|
| label | <code>String</code> | Label for this form field |
|
||||||
| fallbackComponent | <code>String</code> | name of component to be rendered if the API call 403s |
|
| 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**
|
**Example**
|
||||||
|
|
||||||
|
@ -23,6 +26,6 @@ The `SearchSelect` is an implementation of the [ember-power-select-with-create](
|
||||||
**See**
|
**See**
|
||||||
|
|
||||||
- [Uses of SearchSelect](https://github.com/hashicorp/vault/search?l=Handlebars&q=SearchSelect+OR+search-select)
|
- [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 { alias } from '@ember/object/computed';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
import { copy } from 'ember-copy';
|
||||||
|
import { resolve } from 'rsvp';
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
token: null,
|
token: null,
|
||||||
id: null,
|
id: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
errors: [],
|
errors: [],
|
||||||
showFilterConfig: false,
|
|
||||||
primary_api_addr: null,
|
primary_api_addr: null,
|
||||||
primary_cluster_addr: null,
|
primary_cluster_addr: null,
|
||||||
filterConfig: {
|
filterConfig: {
|
||||||
mode: 'whitelist',
|
mode: null,
|
||||||
paths: [],
|
paths: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Controller.extend(DEFAULTS, {
|
export default Controller.extend(copy(DEFAULTS, true), {
|
||||||
store: service(),
|
store: service(),
|
||||||
rm: service('replication-mode'),
|
rm: service('replication-mode'),
|
||||||
replicationMode: alias('rm.mode'),
|
replicationMode: alias('rm.mode'),
|
||||||
|
@ -34,12 +35,16 @@ export default Controller.extend(DEFAULTS, {
|
||||||
const config = this.get('filterConfig');
|
const config = this.get('filterConfig');
|
||||||
const id = this.get('id');
|
const id = this.get('id');
|
||||||
config.id = 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));
|
return configRecord.save().catch(e => this.submitError(e));
|
||||||
},
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.setProperties(DEFAULTS);
|
this.setProperties(copy(DEFAULTS, true));
|
||||||
},
|
},
|
||||||
|
|
||||||
submitSuccess(resp, action) {
|
submitSuccess(resp, action) {
|
||||||
|
@ -66,14 +71,9 @@ export default Controller.extend(DEFAULTS, {
|
||||||
|
|
||||||
submitHandler(action, clusterMode, data, event) {
|
submitHandler(action, clusterMode, data, event) {
|
||||||
const replicationMode = this.get('replicationMode');
|
const replicationMode = this.get('replicationMode');
|
||||||
let saveFilterConfig;
|
|
||||||
if (event && event.preventDefault) {
|
if (event && event.preventDefault) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
if (data && isPresent(data.saveFilterConfig)) {
|
|
||||||
saveFilterConfig = data.saveFilterConfig;
|
|
||||||
delete data.saveFilterConfig;
|
|
||||||
}
|
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
loading: true,
|
loading: true,
|
||||||
errors: [],
|
errors: [],
|
||||||
|
@ -93,13 +93,9 @@ export default Controller.extend(DEFAULTS, {
|
||||||
.replicationAction(action, replicationMode, clusterMode, data)
|
.replicationAction(action, replicationMode, clusterMode, data)
|
||||||
.then(
|
.then(
|
||||||
resp => {
|
resp => {
|
||||||
if (saveFilterConfig) {
|
return this.saveFilterConfig().then(() => {
|
||||||
return this.saveFilterConfig().then(() => {
|
|
||||||
return this.submitSuccess(resp, action, clusterMode);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return this.submitSuccess(resp, action, clusterMode);
|
return this.submitSuccess(resp, action, clusterMode);
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
(...args) => this.submitError(...args)
|
(...args) => this.submitError(...args)
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,11 +2,6 @@ import { alias } from '@ember/object/computed';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
const CONFIG_DEFAULTS = {
|
|
||||||
mode: 'whitelist',
|
|
||||||
paths: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
flashMessages: service(),
|
flashMessages: service(),
|
||||||
rm: service('replication-mode'),
|
rm: service('replication-mode'),
|
||||||
|
@ -14,13 +9,18 @@ export default Controller.extend({
|
||||||
actions: {
|
actions: {
|
||||||
resetConfig(config) {
|
resetConfig(config) {
|
||||||
if (config.get('isNew')) {
|
if (config.get('isNew')) {
|
||||||
config.setProperties(CONFIG_DEFAULTS);
|
config.setProperties({
|
||||||
|
mode: null,
|
||||||
|
paths: [],
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
config.rollbackAttributes();
|
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 flash = this.get('flashMessages');
|
||||||
const id = config.id;
|
const id = config.id;
|
||||||
const redirectArgs = isDelete
|
const redirectArgs = isDelete
|
||||||
|
|
|
@ -1,26 +1,17 @@
|
||||||
import { hash } from 'rsvp';
|
|
||||||
import Base from '../../replication-base';
|
import Base from '../../replication-base';
|
||||||
|
|
||||||
export default Base.extend({
|
export default Base.extend({
|
||||||
model() {
|
model() {
|
||||||
return hash({
|
return this.modelFor('mode.secondaries');
|
||||||
cluster: this.modelFor('mode.secondaries'),
|
|
||||||
mounts: this.fetchMounts(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
redirect(model) {
|
redirect(model) {
|
||||||
const replicationMode = this.paramsFor('mode').replication_mode;
|
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);
|
return this.transitionTo('mode', replicationMode);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setupController(controller, model) {
|
|
||||||
controller.set('model', model.cluster);
|
|
||||||
controller.set('mounts', model.mounts);
|
|
||||||
},
|
|
||||||
|
|
||||||
resetController(controller) {
|
resetController(controller) {
|
||||||
controller.reset();
|
controller.reset();
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,21 +10,23 @@ export default Base.extend({
|
||||||
findOrCreate(id) {
|
findOrCreate(id) {
|
||||||
const flash = this.get('flashMessages');
|
const flash = this.get('flashMessages');
|
||||||
return this.store
|
return this.store
|
||||||
.findRecord('mount-filter-config', id)
|
.findRecord('path-filter-config', id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// if we find a record, transition to the edit view
|
// if we find a record, transition to the edit view
|
||||||
return this.transitionTo('mode.secondaries.config-edit', id)
|
return this.transitionTo('mode.secondaries.config-edit', id)
|
||||||
.followRedirects()
|
.followRedirects()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
flash.info(
|
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 => {
|
.catch(e => {
|
||||||
if (e.httpStatus === 404) {
|
if (e.httpStatus === 404) {
|
||||||
return this.store.createRecord('mount-filter-config', {
|
return this.store.createRecord('path-filter-config', {
|
||||||
id,
|
id,
|
||||||
|
mode: null,
|
||||||
|
paths: [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -49,7 +51,6 @@ export default Base.extend({
|
||||||
return hash({
|
return hash({
|
||||||
cluster: this.modelFor('mode'),
|
cluster: this.modelFor('mode'),
|
||||||
config: this.findOrCreate(params.secondary_id),
|
config: this.findOrCreate(params.secondary_id),
|
||||||
mounts: this.fetchMounts(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,8 +7,7 @@ export default Base.extend({
|
||||||
model(params) {
|
model(params) {
|
||||||
return hash({
|
return hash({
|
||||||
cluster: this.modelFor('mode.secondaries'),
|
cluster: this.modelFor('mode.secondaries'),
|
||||||
config: this.store.findRecord('mount-filter-config', params.secondary_id),
|
config: this.store.findRecord('path-filter-config', params.secondary_id),
|
||||||
mounts: this.fetchMounts(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ export default Base.extend({
|
||||||
const id = params.secondary_id;
|
const id = params.secondary_id;
|
||||||
return hash({
|
return hash({
|
||||||
cluster: this.modelFor('application'),
|
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) {
|
if (e.httpStatus === 404) {
|
||||||
// return an empty obj to let them nav to create
|
// return an empty obj to let them nav to create
|
||||||
return resolve({ id });
|
return resolve({ id });
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { alias } from '@ember/object/computed';
|
import { alias } from '@ember/object/computed';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import { hash, resolve } from 'rsvp';
|
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import UnloadModelRouteMixin from 'vault/mixins/unload-model-route';
|
import UnloadModelRouteMixin from 'vault/mixins/unload-model-route';
|
||||||
|
|
||||||
|
@ -9,14 +8,5 @@ export default Route.extend(UnloadModelRouteMixin, {
|
||||||
version: service(),
|
version: service(),
|
||||||
rm: service('replication-mode'),
|
rm: service('replication-mode'),
|
||||||
modelPath: 'model.config',
|
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'),
|
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">
|
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||||
<h4 class="title is-5">
|
<h4 class="title is-5">
|
||||||
Generate a secondary token
|
Generate a secondary token
|
||||||
|
@ -51,24 +51,11 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{#if (eq replicationMode "performance")}}
|
{{#if (eq replicationMode "performance")}}
|
||||||
<div class="field">
|
<PathFilterConfigList
|
||||||
{{toggle-button
|
@paths={{paths}}
|
||||||
toggleTarget=this
|
@config={{filterConfig}}
|
||||||
toggleAttr='showFilterConfig'
|
@id={{id}}
|
||||||
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>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<div class="box is-fullwidth is-shadowless is-marginless">
|
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||||
<h4 class="title is-5 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>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<form {{action "saveConfig" model.config on="submit"}}>
|
<form {{action "saveConfig" model.config on="submit"}}>
|
||||||
{{mount-filter-config-list
|
{{path-filter-config-list
|
||||||
mounts=model.mounts
|
paths=model.paths
|
||||||
config=model.config
|
config=model.config
|
||||||
}}
|
}}
|
||||||
<div class="field is-grouped box is-fullwidth is-shadowless">
|
<div class="field is-grouped box is-fullwidth is-shadowless">
|
||||||
|
|
|
@ -1,31 +1,19 @@
|
||||||
<Toolbar>
|
<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>
|
|
||||||
|
|
||||||
<div class="box is-fullwidth is-shadowless is-marginless">
|
<div class="box is-fullwidth is-shadowless is-marginless">
|
||||||
<h4 class="title is-5 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>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<form {{action "saveConfig" model.config on="submit"}}>
|
<form {{action "saveConfig" model.config on="submit"}}>
|
||||||
{{mount-filter-config-list
|
<PathFilterConfigList
|
||||||
mounts=model.mounts
|
@paths={{model.paths}}
|
||||||
config=model.config
|
@config={{model.config}}
|
||||||
}}
|
/>
|
||||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-primary" >
|
<button type="submit" class="button is-primary" >
|
||||||
Save
|
Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<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