ui: Centralized Config Intention Permission CRUD (#8762)

This commit is contained in:
John Cowen 2020-09-30 12:33:01 +01:00 committed by GitHub
parent 5261bb0d0d
commit ed9826bbbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1325 additions and 313 deletions

View File

@ -1,4 +1,5 @@
import Adapter, { DATACENTER_QUERY_PARAM as API_DATACENTER_KEY } from './application'; import Adapter, { DATACENTER_QUERY_PARAM as API_DATACENTER_KEY } from './application';
import { get } from '@ember/object';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc'; import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
// Intentions use SourceNS and DestinationNS properties for namespacing // Intentions use SourceNS and DestinationNS properties for namespacing
// so we don't need to add the `?ns=` anywhere here // so we don't need to add the `?ns=` anywhere here
@ -25,55 +26,66 @@ export default Adapter.extend({
if (typeof id === 'undefined') { if (typeof id === 'undefined') {
throw new Error('You must specify an id'); throw new Error('You must specify an id');
} }
// get the information we need from the id, which has been previously encoded
const [SourceNS, SourceName, DestinationNS, DestinationName] = id const [SourceNS, SourceName, DestinationNS, DestinationName] = id
.split(':') .split(':')
.map(decodeURIComponent); .map(decodeURIComponent);
return request` return request`
GET /v1/connect/intentions/exact?source=${SourceNS + GET /v1/connect/intentions/exact?${{
'/' + source: `${SourceNS}/${SourceName}`,
SourceName}&destination=${DestinationNS + '/' + DestinationName}&${{ dc }} destination: `${DestinationNS}/${DestinationName}`,
dc: dc,
}}
Cache-Control: no-store Cache-Control: no-store
${{ index }} ${{ index }}
`; `;
}, },
requestForCreateRecord: function(request, serialized, data) { requestForCreateRecord: function(request, serialized, data) {
// TODO: need to make sure we remove dc const body = {
return request` SourceNS: serialized.SourceNS,
POST /v1/connect/intentions?${{ [API_DATACENTER_KEY]: data[DATACENTER_KEY] }} DestinationNS: serialized.DestinationNS,
SourceName: serialized.SourceName,
DestinationName: serialized.DestinationName,
SourceType: serialized.SourceType,
Meta: serialized.Meta,
Description: serialized.Description,
};
${{ // only send the Action if we have one
SourceNS: serialized.SourceNS, if (get(serialized, 'Action.length')) {
DestinationNS: serialized.DestinationNS, body.Action = serialized.Action;
SourceName: serialized.SourceName, } else {
DestinationName: serialized.DestinationName, // otherwise only send Permissions if we have them
SourceType: serialized.SourceType, if (serialized.Permissions) {
Action: serialized.Action, body.Permissions = serialized.Permissions;
Description: serialized.Description, }
}
return request`
PUT /v1/connect/intentions/exact?${{
source: `${data.SourceNS}/${data.SourceName}`,
destination: `${data.DestinationNS}/${data.DestinationName}`,
[API_DATACENTER_KEY]: data[DATACENTER_KEY],
}} }}
${body}
`; `;
}, },
requestForUpdateRecord: function(request, serialized, data) { requestForUpdateRecord: function(request, serialized, data) {
return request` // you can no longer save Destinations
PUT /v1/connect/intentions/${data.LegacyID}?${{ [API_DATACENTER_KEY]: data[DATACENTER_KEY] }} delete serialized.DestinationNS;
delete serialized.DestinationName;
${{ return this.requestForCreateRecord(...arguments);
SourceNS: serialized.SourceNS,
DestinationNS: serialized.DestinationNS,
SourceName: serialized.SourceName,
DestinationName: serialized.DestinationName,
SourceType: serialized.SourceType,
Action: serialized.Action,
Meta: serialized.Meta,
Description: serialized.Description,
}}
`;
}, },
requestForDeleteRecord: function(request, serialized, data) { requestForDeleteRecord: function(request, serialized, data) {
return request` return request`
DELETE /v1/connect/intentions/${data.LegacyID}?${{ DELETE /v1/connect/intentions/exact?${{
[API_DATACENTER_KEY]: data[DATACENTER_KEY], source: `${data.SourceNS}/${data.SourceName}`,
}} destination: `${data.DestinationNS}/${data.DestinationName}`,
[API_DATACENTER_KEY]: data[DATACENTER_KEY],
}}
`; `;
}, },
}); });

View File

@ -92,7 +92,9 @@
{{nspace.Name}} {{nspace.Name}}
{{/if}} {{/if}}
</PowerSelectWithCreate> </PowerSelectWithCreate>
{{#if create}}
<em>For the destination, you may choose any namespace for which you have access.</em> <em>For the destination, you may choose any namespace for which you have access.</em>
{{/if}}
</label> </label>
{{/if}} {{/if}}
</fieldset> </fieldset>
@ -112,12 +114,17 @@
header="Deny" header="Deny"
body="The source service will not be allowed to connect to the destination." body="The source service will not be allowed to connect to the destination."
) )
(hash
intent=""
header="L7 Permissions"
body="The source service may or may not connect to the destination service via unique permissions based on L7 criteria: path, header, or method."
)
) )
as |_action|}} as |_action|}}
<RadioCard <RadioCard
class={{concat 'value-' _action.intent}} class={{concat 'value-' _action.intent}}
@value={{_action.intent}} @value={{_action.intent}}
@checked={{if (eq item.Action _action.intent) 'checked'}} @checked={{if (eq (or item.Action '') _action.intent) 'checked'}}
@onchange={{action onchange}} @onchange={{action onchange}}
@name="Action" @name="Action"
as |radio|> as |radio|>
@ -131,13 +138,10 @@
{{/each}} {{/each}}
</div> </div>
</div> </div>
<label class="type-text{{if item.error.Description ' has-error'}}">
<span>Description (Optional)</span>
<input type="text" name="Description" value={{item.Description}} placeholder="Description (Optional)" onchange={{action onchange}} />
</label>
</fieldset> </fieldset>
{{#if (not item.Legacy)}} {{#if (eq (or item.Action '') '')}}
<fieldset> <fieldset class="permissions">
<button type="button" onclick={{action (mut shouldShowPermissionForm) true}}>Add permission</button>
<h2>Permissions</h2> <h2>Permissions</h2>
{{#if (gt item.Permissions.length 0) }} {{#if (gt item.Permissions.length 0) }}
<div class="notice info"> <div class="notice info">
@ -148,17 +152,21 @@
<a href="{{env 'CONSUL_DOCS_URL'}}/guides/acl-migrate-tokens.html" target="_blank" rel="noopener noreferrer">documentation</a> <a href="{{env 'CONSUL_DOCS_URL'}}/guides/acl-migrate-tokens.html" target="_blank" rel="noopener noreferrer">documentation</a>
</p> </p>
</div> </div>
<ConsulIntentionPermissionList @items={{item.Permissions}} /> <ConsulIntentionPermissionList
@items={{item.Permissions}}
@onclick={{queue (action (mut permission)) (action (mut shouldShowPermissionForm) true)}}
@ondelete={{action 'delete' 'Permissions' item}}
/>
{{else}} {{else}}
<EmptyState> <EmptyState>
<BlockSlot @name="header"> <BlockSlot @name="header">
<h3> <h3>
Add permissions via CLI No permissions yet
</h3> </h3>
</BlockSlot> </BlockSlot>
<BlockSlot @name="body"> <BlockSlot @name="body">
<p> <p>
Permissions intercept an Intention's traffic using L7 criteria, such as path prefixes and http headers. You can use the CLI to add permissions to this intention. Permissions intercept an Intention's traffic using L7 criteria, such as path prefixes and http headers.
</p> </p>
</BlockSlot> </BlockSlot>
<BlockSlot @name="actions"> <BlockSlot @name="actions">
@ -173,4 +181,47 @@
{{/if}} {{/if}}
</fieldset> </fieldset>
{{/if}} {{/if}}
<fieldset>
<label class="type-text{{if item.error.Description ' has-error'}}">
<span>Description (Optional)</span>
<input type="text" name="Description" value={{item.Description}} placeholder="Description (Optional)" onchange={{action onchange}} />
</label>
</fieldset>
{{#if shouldShowPermissionForm}}
<ModalDialog
class="consul-intention-permission-modal"
@onclose={{queue (action (mut shouldShowPermissionForm) false) (action (mut permission) undefined)}}
as |modal|>
<BlockSlot @name="header">
<h3>Edit Permission</h3>
</BlockSlot>
<BlockSlot @name="body">
<ConsulIntentionPermissionForm
@item={{permission}}
@onsubmit={{action 'add' 'Permissions' item}}
as |permissionForm|>
<Ref @target={{this}} @name="permissionForm" @value={{permissionForm}} />
</ConsulIntentionPermissionForm>
</BlockSlot>
<BlockSlot @name="actions">
<button
type="button"
class="type-submit"
disabled={{if (not this.permissionForm.isDirty) 'disabled'}}
onclick={{queue (action this.permissionForm.submit) (action modal.close)}}
>
Save
</button>
<button
type="button"
class="type-cancel"
onclick={{queue (action this.permissionForm.reset) (action modal.close)}}
>
Cancel
</button>
</BlockSlot>
</ModalDialog>
{{/if}}
</div> </div>

View File

@ -2,6 +2,9 @@ import Component from '@ember/component';
export default Component.extend({ export default Component.extend({
tagName: '', tagName: '',
shouldShowPermissionForm: false,
actions: { actions: {
createNewLabel: function(template, term) { createNewLabel: function(template, term) {
return template.replace(/{{term}}/g, term); return template.replace(/{{term}}/g, term);
@ -9,5 +12,17 @@ export default Component.extend({
isUnique: function(items, term) { isUnique: function(items, term) {
return !items.findBy('Name', term); return !items.findBy('Name', term);
}, },
add: function(name, changeset, value) {
if (!(changeset.get(name) || []).includes(value) && value.isNew) {
changeset.pushObject(name, value);
changeset.validate();
}
},
delete: function(name, changeset, value) {
if ((changeset.get(name) || []).includes(value)) {
changeset.removeObject(name, value);
changeset.validate();
}
},
}, },
}); });

View File

@ -0,0 +1,27 @@
.consul-intention-fieldsets {
[role='radiogroup'] {
overflow: visible !important;
display: grid;
grid-gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(270px, auto));
}
.radio-card {
@extend %radio-card-with-icon;
}
.radio-card header > * {
display: inline;
}
.value-allow > :last-child::before {
@extend %with-arrow-right-color-icon, %as-pseudo;
}
.value-deny > :last-child::before {
@extend %with-deny-color-icon, %as-pseudo;
}
.permissions > button {
@extend %anchor;
float: right;
}
}
.consul-intention-permission-modal [role="dialog"] > div > div {
width: 100%;
}

View File

@ -0,0 +1,19 @@
@import './skin';
@import './layout';
.consul-intention-list td.source,
.consul-intention-list td.destination {
@extend %tbody-th;
}
.consul-intention-list td strong {
@extend %pill-700;
}
.consul-intention-list td.intent-allow strong {
@extend %pill-allow;
}
.consul-intention-list td.intent-deny strong {
@extend %pill-deny;
}
.consul-intention-list td.intent-l7-rules strong {
@extend %pill-l7;
}

View File

@ -0,0 +1,29 @@
.consul-intention-list {
td {
height: 59px;
}
tr > *:nth-child(1) {
width: calc(30% - 50px);
}
tr > *:nth-child(2) {
width: 100px;
}
tr > *:nth-child(3) {
width: calc(30% - 50px);
}
tr > *:nth-child(4) {
width: calc(40% - 220px);
}
tr > *:nth-child(5) {
width: 160px;
}
tr > *:last-child {
width: 60px;
}
}
@media #{$--lt-horizontal-nav} {
.consul-intention-list tr > :not(.source):not(.destination):not(.intent) {
display: none;
}
}

View File

@ -0,0 +1,5 @@
.consul-intention-list {
td.permissions {
color: $blue-500;
}
}

View File

@ -0,0 +1,135 @@
<div
...attributes
class="consul-intention-permission-form"
>
{{yield (hash
submit=(action 'submit' changeset)
reset=(action 'reset' changeset)
isDirty=(and changeset.isValid)
changeset=changeset
)}}
<fieldset>
<span class="label">
Should this permission allow the source connect to the destination?
</span>
<div role="radiogroup" class={{if changeset.error.Action ' has-error'}}>
{{#each intents as |intent|}}
<label>
<span>{{capitalize intent}}</span>
<input
type="radio"
name="Action"
value={{intent}}
checked={{if (eq changeset.Action intent) 'checked'}}
onchange={{action (changeset-set changeset 'Action') value="target.value"}}
/>
</label>
{{/each}}
</div>
</fieldset>
<fieldset>
<header>
<h2>Path</h2>
</header>
<div>
<label class="type-select">
<span>Path Type</span>
<PowerSelect
@options={{pathTypes}}
@selected={{pathType}}
@onChange={{action 'change' 'HTTP.PathType' changeset}} as |Type|>
{{get pathLabels Type}}
</PowerSelect>
</label>
{{#if shouldShowPathField}}
<label class="type-text{{if changeset.error.HTTP.Path ' has-error'}}">
<span>{{get pathLabels pathType}}</span>
<input
type="text"
name="Path"
value={{changeset-get changeset 'HTTP.Path'}}
oninput={{action 'change' 'HTTP.Path' changeset}}
/>
{{#if changeset.error.HTTP.Path}}
<strong>
{{#if (eq (changeset-get changeset 'HTTP.PathType') 'PathRegex')}}
Path Regex should not be blank
{{else}}
Path should begin with a '/'
{{/if}}
</strong>
{{/if}}
</label>
{{/if}}
</div>
</fieldset>
<fieldset>
<h2>Methods</h2>
<div class="type-toggle">
<span>All methods are applied by default unless specified</span>
<label class="type-checkbox">
<input
type="checkbox"
name="{{name}}[allMethods]"
checked={{if allMethods 'checked'}}
onchange={{action 'change' 'allMethods' changeset}}
/>
<span>All methods</span>
</label>
</div>
{{#if shouldShowMethods}}
<div class="checkbox-group" role="group">
{{#each methods as |method|}}
<label class="type-checkbox">
<input
type="checkbox"
name="method"
value={{method}}
checked={{if (contains method changeset.HTTP.Methods) 'checked'}}
onchange={{action 'change' 'method' changeset}}
/>
<span>{{method}}</span>
</label>
{{/each}}
</div>
{{/if}}
</fieldset>
<fieldset>
<h2>Headers</h2>
<ConsulIntentionPermissionHeaderList
@items={{changeset-get changeset 'HTTP.Header'}}
@ondelete={{action 'delete' 'HTTP.Header' changeset}}
as |headerList|>
</ConsulIntentionPermissionHeaderList>
<ConsulIntentionPermissionHeaderForm
@onsubmit={{action 'add' 'HTTP.Header' changeset}}
as |headerForm|>
<Ref @target={{this}} @name="headerForm" @value={{headerForm}} />
</ConsulIntentionPermissionHeaderForm>
<button
type="button"
class="type-submit"
disabled={{if (not this.headerForm.isDirty) 'disabled'}}
onclick={{action this.headerForm.submit}}
>
Add another header
</button>
<button
type="button"
class="type-cancel"
onclick={{action this.headerForm.reset}}
>
Cancel
</button>
</fieldset>
</div>

View File

@ -0,0 +1,123 @@
import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
import { alias, not, equal } from '@ember/object/computed';
import { inject as service } from '@ember/service';
const name = 'intention-permission';
export default Component.extend({
tagName: '',
name: name,
schema: service('schema'),
change: service('change'),
repo: service(`repository/${name}`),
onsubmit: function() {},
onreset: function() {},
intents: alias(`schema.${name}.Action.allowedValues`),
methods: alias(`schema.${name}-http.Methods.allowedValues`),
pathProps: alias(`schema.${name}-http.PathType.allowedValues`),
pathTypes: computed('pathProps', function() {
return ['NoPath'].concat(this.pathProps);
}),
pathLabels: computed(function() {
return {
NoPath: 'No Path',
PathExact: 'Exact',
PathPrefix: 'Prefixed by',
PathRegex: 'Regular Expression',
};
}),
pathInputLabels: computed(function() {
return {
PathExact: 'Exact Path',
PathPrefix: 'Path Prefix',
PathRegex: 'Path Regular Expression',
};
}),
changeset: computed('item', function() {
const changeset = this.change.changesetFor(name, this.item || this.repo.create());
if (changeset.isNew) {
changeset.validate();
}
return changeset;
}),
pathType: computed('changeset._changes.HTTP.PathType', 'pathTypes.firstObject', function() {
return this.changeset.HTTP.PathType || this.pathTypes.firstObject;
}),
noPathType: equal('pathType', 'NoPath'),
shouldShowPathField: not('noPathType'),
allMethods: false,
shouldShowMethods: not('allMethods'),
didReceiveAttrs: function() {
if (!get(this, 'item.HTTP.Methods.length')) {
set(this, 'allMethods', true);
}
},
actions: {
change: function(name, changeset, e) {
const value = typeof get(e, 'target.value') !== 'undefined' ? e.target.value : e;
switch (name) {
case 'allMethods':
set(this, name, e.target.checked);
break;
case 'method':
if (e.target.checked) {
this.actions.add.apply(this, ['HTTP.Methods', changeset, value]);
} else {
this.actions.delete.apply(this, ['HTTP.Methods', changeset, value]);
}
break;
default:
changeset.set(name, value);
}
changeset.validate();
},
add: function(prop, changeset, value) {
changeset.pushObject(prop, value);
changeset.validate();
},
delete: function(prop, changeset, value) {
changeset.removeObject(prop, value);
changeset.validate();
},
submit: function(changeset, e) {
const pathChanged =
typeof changeset.changes.find(
({ key, value }) => key === 'HTTP.PathType' || key === 'HTTP.Path'
) !== 'undefined';
if (pathChanged) {
this.pathProps.forEach(prop => {
changeset.set(`HTTP.${prop}`, undefined);
});
if (changeset.HTTP.PathType !== 'NoPath') {
changeset.set(`HTTP.${changeset.HTTP.PathType}`, changeset.HTTP.Path);
}
}
if (this.allMethods) {
changeset.set('HTTP.Methods', null);
}
// this will prevent the changeset from overwriting the
// computed properties on the ED object
delete changeset._changes.HTTP.PathType;
delete changeset._changes.HTTP.Path;
//
this.repo.persist(changeset);
this.onsubmit(changeset.data);
},
reset: function(changeset, e) {
changeset.rollback();
this.onreset(changeset.data);
},
},
});

View File

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

View File

@ -0,0 +1,22 @@
.consul-intention-permission-form {
h2 {
padding-top: 1.4em;
margin-top: .2em;
margin-bottom: .6em;
}
.consul-intention-permission-header-form {
margin-top: 10px;
}
fieldset:nth-child(2) > div,
.consul-intention-permission-header-form fieldset > div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-gap: 12px;
}
fieldset:nth-child(2) > div label:last-child {
grid-column: span 2;
}
.ember-basic-dropdown-trigger {
padding: 5px;
}
}

View File

@ -0,0 +1,6 @@
.consul-intention-permission-form {
h2 {
border-top: 1px solid $blue-500;
}
}

View File

@ -0,0 +1,57 @@
<div
...attributes
class="consul-intention-permission-header-form"
>
{{yield (hash
submit=(action 'submit' changeset)
reset=(action 'reset' changeset)
isDirty=(and changeset.isValid changeset.isDirty)
changeset=changeset
)}}
<fieldset>
<div>
<label class="type-select">
<span>Header Type</span>
<div>
<PowerSelect
@options={{headerTypes}}
@selected={{headerType}}
@onChange={{action 'change' 'HeaderType' changeset}} as |Type|>
{{get headerLabels Type}}
</PowerSelect>
</div>
</label>
<label class="type-text{{if changeset.error.Name ' has-error'}}">
<span>Header name</span>
<input
type="text"
name={{concat name '[Name]'}}
value={{changeset-get changeset 'Name'}}
oninput={{action 'change' 'Name' changeset}}
/>
{{#if changeset.error.Name}}
<strong>{{changeset.error.Name.validation}}</strong>
{{/if}}
</label>
{{#if shouldShowValueField}}
<label class="type-text{{if changeset.error.Value ' has-error'}}">
<span>Header {{lowercase (get headerLabels headerType)}}</span>
<input
type="text"
name="Value"
value={{changeset-get changeset 'Value'}}
oninput={{action 'change' 'Value' changeset}}
/>
{{#if changeset.error.Value}}
<strong>{{changeset.error.Value.validation}}</strong>
{{/if}}
</label>
{{/if}}
</div>
</fieldset>
</div>

View File

@ -0,0 +1,85 @@
import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
import { alias, equal, not } from '@ember/object/computed';
import { inject as service } from '@ember/service';
const name = 'intention-permission-http-header';
export default Component.extend({
tagName: '',
name: name,
schema: service('schema'),
change: service('change'),
repo: service(`repository/${name}`),
onsubmit: function() {},
onreset: function() {},
changeset: computed('item', function() {
return this.change.changesetFor(
name,
this.item ||
this.repo.create({
HeaderType: this.headerTypes.firstObject,
})
);
}),
headerTypes: alias(`schema.${name}.HeaderType.allowedValues`),
headerLabels: computed(function() {
return {
Exact: 'Exactly Matching',
Prefix: 'Prefixed by',
Suffix: 'Suffixed by',
Regex: 'Regular Expression',
Present: 'Is present',
};
}),
headerType: computed('changeset.HeaderType', 'headerTypes.firstObject', function() {
return this.changeset.HeaderType || this.headerTypes.firstObject;
}),
headerTypeEqualsPresent: equal('headerType', 'Present'),
shouldShowValueField: not('headerTypeEqualsPresent'),
actions: {
change: function(name, changeset, e) {
const value = typeof get(e, 'target.value') !== 'undefined' ? e.target.value : e;
switch (name) {
default:
changeset.set(name, value);
}
changeset.validate();
},
submit: function(changeset) {
this.headerTypes.forEach(prop => {
changeset.set(prop, undefined);
});
// Present is a boolean, whereas all other header types have a value
const value = changeset.HeaderType === 'Present' ? true : changeset.Value;
changeset.set(changeset.HeaderType, value);
// this will prevent the changeset from overwriting the
// computed properties on the ED object
delete changeset._changes.HeaderType;
delete changeset._changes.Value;
//
this.repo.persist(changeset);
this.onsubmit(changeset.data);
set(
this,
'item',
this.repo.create({
HeaderType: this.headerType,
})
);
},
reset: function(changeset, e) {
changeset.rollback();
},
},
});

View File

@ -0,0 +1,45 @@
{{#if (gt items.length 0)}}
<ListCollection
class="consul-intention-permission-header-list"
@items={{items}}
@scroll="native"
@cellHeight={{42}}
as |item|>
<BlockSlot @name="details">
<dl>
<dt>
<Tooltip>
Header
</Tooltip>
</dt>
<dd>
{{item.Name}} {{route-match item}}
</dd>
</dl>
</BlockSlot>
<BlockSlot @name="actions" as |Actions|>
<Actions as |Action|>
<Action data-test-delete-action @onclick={{action ondelete item}} class="dangerous">
<BlockSlot @name="label">
Delete
</BlockSlot>
<BlockSlot @name="confirmation" as |Confirmation|>
<Confirmation class="warning">
<BlockSlot @name="header">
Confirm delete
</BlockSlot>
<BlockSlot @name="body">
<p>
Are you sure you want to delete this header?
</p>
</BlockSlot>
<BlockSlot @name="confirm" as |Confirm|>
<Confirm>Delete</Confirm>
</BlockSlot>
</Confirmation>
</BlockSlot>
</Action>
</Actions>
</BlockSlot>
</ListCollection>
{{/if}}

View File

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

View File

@ -0,0 +1,8 @@
@import './skin';
@import './layout';
.consul-intention-permission-header-list {
> ul > li {
@extend %list-row-200;
}
}

View File

@ -0,0 +1,7 @@
.consul-intention-permission-header-list {
dt::before {
@extend %with-glyph-icon;
content: 'H';
}
}

View File

@ -1,35 +1,83 @@
{{#if (gt items.length 0)}} {{#if (gt items.length 0)}}
<div class="consul-intention-permission-list"> <ListCollection
<ul> class="consul-intention-permission-list{{if (not onclick) ' readonly'}}"
{{#each items as |item|}} @scroll="native"
<li> @items={{items}}
@cellHeight={{42}}
as |item|>
<BlockSlot @name="details">
<div onclick={{action (optional onclick) item}}>
<strong class={{concat 'intent-' item.Action}}>{{item.Action}}</strong> <strong class={{concat 'intent-' item.Action}}>{{item.Action}}</strong>
{{#if item.Http.Path}} {{#if (gt item.HTTP.Methods.length 0)}}
<dl class="route-path"> <dl class="permission-methods">
<dt> <dt>
<Tooltip> <Tooltip>
{{item.Http.PathType}} Methods
</Tooltip> </Tooltip>
</dt> </dt>
<dd> <dd>
{{item.Http.Path}} {{#each item.HTTP.Methods as |item|}}
{{item}}
{{/each}}
</dd> </dd>
</dl> </dl>
{{/if}} {{/if}}
{{#each item.Http.Header as |item|}} {{#if item.HTTP.Path}}
<dl class="route-header"> <dl class="permission-path">
<dt>
<Tooltip>
{{item.HTTP.PathType}}
</Tooltip>
</dt>
<dd>
{{item.HTTP.Path}}
</dd>
</dl>
{{/if}}
{{#each item.HTTP.Header as |item|}}
<dl class="permission-header">
<dt> <dt>
<Tooltip> <Tooltip>
Header Header
</Tooltip> </Tooltip>
</dt> </dt>
<dd> <dd>
{{item.Name}} {{route-match item}} {{item.Name}} {{route-match item}}
</dd> </dd>
</dl> </dl>
{{/each}} {{/each}}
</li> </div>
{{/each}} </BlockSlot>
</ul> {{#if onclick}}
</div> <BlockSlot @name="actions" as |Actions|>
<Actions as |Action|>
<Action data-test-edit-action @onclick={{action (optional onclick) item}} @close={{true}}>
<BlockSlot @name="label">
Edit
</BlockSlot>
</Action>
<Action data-test-delete-action @onclick={{action ondelete item}} class="dangerous">
<BlockSlot @name="label">
Delete
</BlockSlot>
<BlockSlot @name="confirmation" as |Confirmation|>
<Confirmation class="warning">
<BlockSlot @name="header">
Confirm delete
</BlockSlot>
<BlockSlot @name="body">
<p>
Are you sure you want to delete this permission?
</p>
</BlockSlot>
<BlockSlot @name="confirm" as |Confirm|>
<Confirm>Delete</Confirm>
</BlockSlot>
</Confirmation>
</BlockSlot>
</Action>
</Actions>
</BlockSlot>
{{/if}}
</ListCollection>
{{/if}} {{/if}}

View File

@ -0,0 +1,36 @@
@import './skin';
@import './layout';
%list-row-200 {
@extend %list-row;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
%list-row-200 .detail {
grid-row-start: header !important;
grid-row-end: detail !important;
align-self: center !important;
}
%list-row-200 .popover-menu > [type="checkbox"] + label {
padding: 0;
}
%list-row-200 .popover-menu > [type="checkbox"] + label + div:not(.above) {
top: 30px;
}
%list-row-200 dd {
@extend %p2;
}
.consul-intention-permission-list > ul > li {
@extend %list-row-200;
}
.consul-intention-permission-list:not(.readonly) > ul > li {
@extend %with-composite-row-intent;
}
.consul-intention-permission-list strong {
@extend %pill-500;
}
.consul-intention-permission-list .intent-allow {
@extend %pill-allow;
}
.consul-intention-permission-list .intent-deny {
@extend %pill-deny;
}

View File

@ -0,0 +1,9 @@
.consul-intention-permission-list {
.detail > div {
display: flex;
width: 100%;
}
strong {
margin-right: 8px;
}
}

View File

@ -0,0 +1,14 @@
.consul-intention-permission-list {
dt::before {
@extend %with-glyph-icon;
}
dl.permission-methods dt::before {
content: 'M';
}
dl.permission-path dt::before {
content: 'P';
}
dl.permission-header dt::before {
content: 'H';
}
}

View File

@ -34,7 +34,9 @@
</p> </p>
</div> </div>
<ConsulIntentionPermissionList @items={{item.Permissions}} @readonly={{true}} /> <ConsulIntentionPermissionList
@items={{item.Permissions}}
/>
{{/if}} {{/if}}
</div> </div>

View File

@ -1,34 +1,69 @@
{{on-window 'resize' (action "resize") }} <div
{{yield}} class="list-collection list-collection-scroll-{{scroll}}"
<EmberNativeScrollable style={{{style}}}
@tagName="ul" id={{guid}}
@content-size={{_contentSize}} ...attributes
@scroll-left={{_scrollLeft}}
@scroll-top={{_scrollTop}}
@scrollChange={{action "scrollChange"}}
@clientSizeChange={{action "clientSizeChange"}}
> >
<li></li> {{yield}}
{{~#each _cells as |cell|~}} {{#if (eq scroll 'virtual')}}
<li {{on-window 'resize' (action "resize") }}
data-test-list-row <EmberNativeScrollable
onclick={{action 'click'}} style={{{cell.style}}} @tagName="ul"
class={{if @content-size={{_contentSize}}
(compute (action (or linkable (noop)) cell.item)) @scroll-left={{_scrollLeft}}
'linkable' @scroll-top={{_scrollTop}}
}} @scrollChange={{action "scrollChange"}}
> @clientSizeChange={{action "clientSizeChange"}}
<YieldSlot @name="header"><div class="header">{{yield cell.item cell.index}}</div></YieldSlot> >
<YieldSlot @name="details"><div class="detail">{{yield cell.item cell.index}}</div></YieldSlot> <li></li>
<YieldSlot @name="actions" {{~#each _cells as |cell|~}}
@params={{ <li
block-params (component 'more-popover-menu' expanded=(if (eq checked cell.index) true false) onchange=(action "change" cell.index)) data-test-list-row
onclick={{action 'click'}} style={{{cell.style}}}
class={{if
(compute (action (or linkable (noop)) cell.item))
'linkable'
}} }}
> >
<div class="actions"> <YieldSlot @name="header"><div class="header">{{yield cell.item cell.index}}</div></YieldSlot>
{{yield cell.item cell.index}} <YieldSlot @name="details"><div class="detail">{{yield cell.item cell.index}}</div></YieldSlot>
</div> <YieldSlot @name="actions"
</YieldSlot> @params={{
</li> block-params (component 'more-popover-menu' expanded=(if (eq checked cell.index) true false) onchange=(action "change" cell.index))
{{~/each~}} }}
</EmberNativeScrollable> >
<div class="actions">
{{yield cell.item cell.index}}
</div>
</YieldSlot>
</li>
{{~/each~}}
</EmberNativeScrollable>
{{else}}
<ul>
<li style="display: none;"></li>
{{~#each items as |item index|~}}
<li
data-test-list-row
onclick={{action 'click'}}
class={{if
(compute (action (or linkable (noop)) item))
'linkable'
}}
>
<YieldSlot @name="header"><div class="header">{{yield item index}}</div></YieldSlot>
<YieldSlot @name="details"><div class="detail">{{yield item index}}</div></YieldSlot>
<YieldSlot @name="actions"
@params={{
block-params (component 'more-popover-menu' onchange=(action "change" index))
}}
>
<div class="actions">
{{yield item index}}
</div>
</YieldSlot>
</li>
{{~/each~}}
</ul>
{{/if}}
</div>

View File

@ -9,20 +9,23 @@ const formatItemStyle = PercentageColumns.prototype.formatItemStyle;
export default Component.extend(Slotted, { export default Component.extend(Slotted, {
dom: service('dom'), dom: service('dom'),
tagName: 'div', tagName: '',
attributeBindings: ['style'],
height: 500, height: 500,
cellHeight: 70, cellHeight: 70,
style: style('getStyle'), style: style('getStyle'),
classNames: ['list-collection'],
checked: null, checked: null,
scroll: 'virtual',
init: function() { init: function() {
this._super(...arguments); this._super(...arguments);
this.columns = [100]; this.columns = [100];
this.guid = this.dom.guid(this);
}, },
didInsertElement: function() { didInsertElement: function() {
this._super(...arguments); this._super(...arguments);
this.actions.resize.apply(this, [{ target: this.dom.viewport() }]); this.$element = this.dom.element(`#${this.guid}`);
if (this.scroll === 'virtual') {
this.actions.resize.apply(this, [{ target: this.dom.viewport() }]);
}
}, },
didReceiveAttrs: function() { didReceiveAttrs: function() {
this._super(...arguments); this._super(...arguments);
@ -41,6 +44,9 @@ export default Component.extend(Slotted, {
}; };
}, },
getStyle: computed('height', function() { getStyle: computed('height', function() {
if (this.scroll !== 'virtual') {
return {};
}
return { return {
height: get(this, 'height'), height: get(this, 'height'),
}; };
@ -49,12 +55,11 @@ export default Component.extend(Slotted, {
resize: function(e) { resize: function(e) {
// TODO: This top part is very similar to resize in tabular-collection // TODO: This top part is very similar to resize in tabular-collection
// see if it make sense to DRY out // see if it make sense to DRY out
const dom = get(this, 'dom'); const $appContent = this.dom.element('main > div');
const $appContent = dom.element('main > div');
if ($appContent) { if ($appContent) {
const border = 1; const border = 1;
const rect = this.element.getBoundingClientRect(); const rect = this.$element.getBoundingClientRect();
const $footer = dom.element('footer[role="contentinfo"]'); const $footer = this.dom.element('footer[role="contentinfo"]');
const space = rect.top + $footer.clientHeight + border; const space = rect.top + $footer.clientHeight + border;
const height = e.target.innerHeight - space; const height = e.target.innerHeight - space;
this.set('height', Math.max(0, height)); this.set('height', Math.max(0, height));

View File

@ -36,6 +36,7 @@ as |components|}}
</ToggleButton> </ToggleButton>
<MenuPanel @position={{position}} id={{aria.controls}} aria-labelledby={{aria.labelledBy}} aria-expanded={{aria.expanded}} as |menu|> <MenuPanel @position={{position}} id={{aria.controls}} aria-labelledby={{aria.labelledBy}} aria-expanded={{aria.expanded}} as |menu|>
<Ref @target={{this}} @name="menu" @value={{menu}} />
<BlockSlot @name="controls"> <BlockSlot @name="controls">
<input type="checkbox" id={{concat 'popover-menu-' guid '-'}} /> <input type="checkbox" id={{concat 'popover-menu-' guid '-'}} />
{{#each submenus as |sub|}} {{#each submenus as |sub|}}

View File

@ -32,7 +32,10 @@
role="menuitem" role="menuitem"
aria-selected={{if selected 'true'}} aria-selected={{if selected 'true'}}
tabindex="-1" tabindex="-1"
onclick={{action (or this.onclick (noop))}}> onclick={{queue
(action (or this.onclick (noop)))
(action (if this.close menu.clickTrigger (noop)))
}}>
<YieldSlot @name="label"> <YieldSlot @name="label">
{{yield}} {{yield}}
</YieldSlot> </YieldSlot>

View File

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

View File

@ -1,17 +1,20 @@
import { helper } from '@ember/component/helper'; import { helper } from '@ember/component/helper';
export default helper(function routeMatch([item], hash) { export default helper(function routeMatch([item], hash) {
const keys = Object.keys(item.data || item); const prop = ['Present', 'Exact', 'Prefix', 'Suffix', 'Regex'].find(
switch (true) { prop => typeof item[prop] !== 'undefined'
case keys.includes('Present'): );
switch (prop) {
case 'Present':
return `${item.Invert ? `NOT ` : ``}present`; return `${item.Invert ? `NOT ` : ``}present`;
case keys.includes('Exact'): case 'Exact':
return `${item.Invert ? `NOT ` : ``}exactly matching "${item.Exact}"`; return `${item.Invert ? `NOT ` : ``}exactly matching "${item.Exact}"`;
case keys.includes('Prefix'): case 'Prefix':
return `${item.Invert ? `NOT ` : ``}prefixed by "${item.Prefix}"`; return `${item.Invert ? `NOT ` : ``}prefixed by "${item.Prefix}"`;
case keys.includes('Suffix'): case 'Suffix':
return `${item.Invert ? `NOT ` : ``}suffixed by "${item.Suffix}"`; return `${item.Invert ? `NOT ` : ``}suffixed by "${item.Suffix}"`;
case keys.includes('Regex'): case 'Regex':
return `${item.Invert ? `NOT ` : ``}matching the regex "${item.Regex}"`; return `${item.Invert ? `NOT ` : ``}matching the regex "${item.Regex}"`;
} }
return ''; return '';

View File

@ -1,7 +1,18 @@
import { computed } from '@ember/object';
import { or } from '@ember/object/computed';
import attr from 'ember-data/attr'; import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment'; import Fragment from 'ember-data-model-fragments/fragment';
export const schema = {
Name: {
required: true,
},
HeaderType: {
allowedValues: ['Exact', 'Prefix', 'Suffix', 'Regex', 'Present'],
},
};
export default Fragment.extend({ export default Fragment.extend({
Name: attr('string'), Name: attr('string'),
@ -9,6 +20,10 @@ export default Fragment.extend({
Prefix: attr('string'), Prefix: attr('string'),
Suffix: attr('string'), Suffix: attr('string'),
Regex: attr('string'), Regex: attr('string'),
Present: attr('boolean'), Present: attr(), // this is a boolean but we don't want it to automatically be set to false
Invert: attr('boolean'),
Value: or(...schema.HeaderType.allowedValues),
HeaderType: computed(...schema.HeaderType.allowedValues, function() {
return schema.HeaderType.allowedValues.find(prop => typeof this[prop] !== 'undefined');
}),
}); });

View File

@ -5,7 +5,15 @@ import { or } from '@ember/object/computed';
import Fragment from 'ember-data-model-fragments/fragment'; import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentArray, array } from 'ember-data-model-fragments/attributes'; import { fragmentArray, array } from 'ember-data-model-fragments/attributes';
const pathProps = ['PathPrefix', 'PathExact', 'PathRegex']; export const schema = {
PathType: {
allowedValues: ['PathPrefix', 'PathExact', 'PathRegex'],
},
Methods: {
allowedValues: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'],
},
};
export default Fragment.extend({ export default Fragment.extend({
PathExact: attr('string'), PathExact: attr('string'),
PathPrefix: attr('string'), PathPrefix: attr('string'),
@ -14,8 +22,8 @@ export default Fragment.extend({
Header: fragmentArray('intention-permission-http-header'), Header: fragmentArray('intention-permission-http-header'),
Methods: array('string'), Methods: array('string'),
Path: or(...pathProps), Path: or(...schema.PathType.allowedValues),
PathType: computed(...pathProps, function() { PathType: computed(...schema.PathType.allowedValues, function() {
return pathProps.find(prop => typeof this[prop] === 'string'); return schema.PathType.allowedValues.find(prop => typeof this[prop] === 'string');
}), }),
}); });

View File

@ -3,7 +3,16 @@ import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment'; import Fragment from 'ember-data-model-fragments/fragment';
import { fragment } from 'ember-data-model-fragments/attributes'; import { fragment } from 'ember-data-model-fragments/attributes';
export const schema = {
Action: {
defaultValue: 'allow',
allowedValues: ['allow', 'deny'],
},
};
export default Fragment.extend({ export default Fragment.extend({
Action: attr('string', { defaultValue: 'allow' }), Action: attr('string', {
Http: fragment('intention-permission-http'), defaultValue: schema.Action.defaultValue,
}),
HTTP: fragment('intention-permission-http'),
}); });

View File

@ -10,17 +10,17 @@ export default Model.extend({
[PRIMARY_KEY]: attr('string'), [PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'), [SLUG_KEY]: attr('string'),
Description: attr('string'), Description: attr('string'),
SourceNS: attr('string'), SourceNS: attr('string', { defaultValue: 'default' }),
SourceName: attr('string', { defaultValue: '*' }), SourceName: attr('string', { defaultValue: '*' }),
DestinationName: attr('string', { defaultValue: '*' }), DestinationName: attr('string', { defaultValue: '*' }),
DestinationNS: attr('string'), DestinationNS: attr('string', { defaultValue: 'default' }),
Precedence: attr('number'), Precedence: attr('number'),
Permissions: fragmentArray('intention-permission'), Permissions: fragmentArray('intention-permission'),
SourceType: attr('string', { defaultValue: 'consul' }), SourceType: attr('string', { defaultValue: 'consul' }),
Action: attr('string'), Action: attr('string'),
Meta: attr(), Meta: attr(),
Legacy: attr('boolean', { defaultValue: true }),
LegacyID: attr('string'), LegacyID: attr('string'),
Legacy: attr('boolean', { defaultValue: true }),
IsManagedByCRD: computed('Meta', function() { IsManagedByCRD: computed('Meta', function() {
const meta = Object.entries(this.Meta || {}).find( const meta = Object.entries(this.Meta || {}).find(
@ -29,7 +29,7 @@ export default Model.extend({
return typeof meta !== 'undefined'; return typeof meta !== 'undefined';
}), }),
IsEditable: computed('Legacy', 'IsManagedByCRD', function() { IsEditable: computed('Legacy', 'IsManagedByCRD', function() {
return this.Legacy && !this.IsManagedByCRD; return !this.IsManagedByCRD;
}), }),
SyncTime: attr('number'), SyncTime: attr('number'),
Datacenter: attr('string'), Datacenter: attr('string'),

View File

@ -6,7 +6,7 @@ export default Route.extend({
nspace: '*', nspace: '*',
dc: this.paramsFor('dc').dc, dc: this.paramsFor('dc').dc,
service: this.paramsFor('dc.services.show').name, service: this.paramsFor('dc.services.show').name,
src: params.intention, src: params.intention_id,
}; };
}, },
setupController: function(controller, model) { setupController: function(controller, model) {

View File

@ -44,16 +44,24 @@ export default Serializer.extend({
query query
); );
}, },
respondForCreateRecord: function(respond, serialized, data) {
const slugKey = this.slugKey;
const primaryKey = this.primaryKey;
return respond((headers, body) => {
body = data;
body.ID = this
.uri`${serialized.SourceNS}:${serialized.SourceName}:${serialized.DestinationNS}:${serialized.DestinationName}`;
return this.fingerprint(primaryKey, slugKey, body.Datacenter)(body);
});
},
respondForUpdateRecord: function(respond, serialized, data) { respondForUpdateRecord: function(respond, serialized, data) {
return this._super( const slugKey = this.slugKey;
cb => const primaryKey = this.primaryKey;
respond((headers, body) => { return respond((headers, body) => {
body.LegacyID = body.ID; body = data;
body.ID = serialized.ID; body.LegacyID = body.ID;
return cb(headers, body); body.ID = serialized.ID;
}), return this.fingerprint(primaryKey, slugKey, body.Datacenter)(body);
serialized, });
data
);
}, },
}); });

View File

@ -0,0 +1,51 @@
import Service, { inject as service } from '@ember/service';
import lookupValidator from 'ember-changeset-validations';
import { Changeset as createChangeset } from 'ember-changeset';
import Changeset from 'consul-ui/utils/form/changeset';
import intentionPermissionValidator from 'consul-ui/validations/intention-permission';
import intentionPermissionHttpHeaderValidator from 'consul-ui/validations/intention-permission-http-header';
const validators = {
'intention-permission': intentionPermissionValidator,
'intention-permission-http-header': intentionPermissionHttpHeaderValidator,
};
export default Service.extend({
schema: service('schema'),
init: function() {
this._super(...arguments);
this._validators = new Map();
},
willDestroy: function() {
this._validators = null;
},
changesetFor: function(modelName, model, options = {}) {
const validator = this.validatorFor(modelName, options);
let changeset;
if (validator) {
let validatorFunc = validator;
if (typeof validator !== 'function') {
validatorFunc = lookupValidator(validator);
}
changeset = createChangeset(model, validatorFunc, validator, { changeset: Changeset });
} else {
changeset = createChangeset(model);
}
return changeset;
},
validatorFor: function(modelName, options = {}) {
if (!this._validators.has(modelName)) {
const factory = validators[modelName];
let validator;
if (typeof factory !== 'undefined') {
validator = factory(this.schema);
}
this._validators.set(modelName, validator);
}
return this._validators.get(modelName);
},
});

View File

@ -2,6 +2,7 @@ import Service, { inject as service } from '@ember/service';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
import { typeOf } from '@ember/utils'; import { typeOf } from '@ember/utils';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { isChangeset } from 'validated-changeset';
export default Service.extend({ export default Service.extend({
getModelName: function() { getModelName: function() {
@ -67,6 +68,13 @@ export default Service.extend({
return this.store.createRecord(this.getModelName(), obj); return this.store.createRecord(this.getModelName(), obj);
}, },
persist: function(item) { persist: function(item) {
// workaround for saving changesets that contain fragments
// firstly commit the changes down onto the object if
// its a changeset, then save as a normal object
if (isChangeset(item)) {
item.execute();
item = item.data;
}
return item.save(); return item.save();
}, },
remove: function(obj) { remove: function(obj) {

View File

@ -0,0 +1,14 @@
import RepositoryService from 'consul-ui/services/repository';
const modelName = 'intention-permission-http-header';
export default RepositoryService.extend({
getModelName: function() {
return modelName;
},
create: function(obj = {}) {
return this.store.createFragment(this.getModelName(), obj);
},
persist: function(item) {
return item.execute();
},
});

View File

@ -0,0 +1,18 @@
import RepositoryService from 'consul-ui/services/repository';
const modelName = 'intention-permission';
export default RepositoryService.extend({
getModelName: function() {
return modelName;
},
create: function(obj = {}) {
// intention-permission and intention-permission-http
// are currently treated as one and the same
return this.store.createFragment(this.getModelName(), {
...obj,
HTTP: this.store.createFragment('intention-permission-http', obj.HTTP || {}),
});
},
persist: function(item) {
return item.execute();
},
});

View File

@ -1,3 +1,4 @@
import { set, get } from '@ember/object';
import RepositoryService from 'consul-ui/services/repository'; import RepositoryService from 'consul-ui/services/repository';
import { PRIMARY_KEY } from 'consul-ui/models/intention'; import { PRIMARY_KEY } from 'consul-ui/models/intention';
const modelName = 'intention'; const modelName = 'intention';
@ -15,6 +16,19 @@ export default RepositoryService.extend({
...obj, ...obj,
}); });
}, },
persist: function(obj) {
return this._super(...arguments).then(res => {
// if Action is set it means we are an l4 type intention
// we don't delete these at a UI level incase the user
// would like to switch backwards and forwards between
// allow/deny/l7 in the forms, but once its been saved
// to the backend we then delete them
if (get(res, 'Action.length')) {
set(res, 'Permissions', []);
}
return res;
});
},
findByService: function(slug, dc, nspace, configuration = {}) { findByService: function(slug, dc, nspace, configuration = {}) {
const query = { const query = {
dc: dc, dc: dc,

View File

@ -0,0 +1,10 @@
import Service from '@ember/service';
import { schema as intentionPermissionSchema } from 'consul-ui/models/intention-permission';
import { schema as intentionPermissionHttpSchema } from 'consul-ui/models/intention-permission-http';
import { schema as intentionPermissionHttpHeaderSchema } from 'consul-ui/models/intention-permission-http-header';
export default Service.extend({
'intention-permission': intentionPermissionSchema,
'intention-permission-http': intentionPermissionHttpSchema,
'intention-permission-http-header': intentionPermissionHttpHeaderSchema,
});

View File

@ -54,6 +54,15 @@
@import './components/footer'; @import './components/footer';
/**/ /**/
@import './components/consul-intention-list';
@import './components/consul-intention-permission-list'; /**
@import './components/consul-intention-fieldsets'; * Migration: We are migrating our consul-* styles to use colocated styles
* consul-* component styles should be moved or added under here
* when convienient
**/
@import 'consul-ui/components/consul-intention-list';
@import 'consul-ui/components/consul-intention-form/fieldsets';
@import 'consul-ui/components/consul-intention-permission-list';
@import 'consul-ui/components/consul-intention-permission-form';
@import 'consul-ui/components/consul-intention-permission-header-list';

View File

@ -1,4 +1,5 @@
button[type='submit'], button[type='submit'],
button.type-submit,
a.type-create { a.type-create {
@extend %primary-button; @extend %primary-button;
} }

View File

@ -1,4 +0,0 @@
@import './consul-intention-fieldsets/index';
.consul-intention-fieldsets {
@extend %consul-intention-fieldsets;
}

View File

@ -1,22 +0,0 @@
%consul-intention-fieldsets [role='radiogroup'] {
overflow: visible !important;
display: grid;
grid-gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(270px, 370px));
}
%consul-intention-fieldsets .radio-card {
@extend %radio-card-with-icon;
}
%consul-intention-fieldsets .radio-card header > * {
display: inline;
}
%consul-intention-fieldsets .value-allow > :last-child::before {
@extend %with-arrow-right-color-icon, %as-pseudo;
}
%consul-intention-fieldsets .value-deny > :last-child::before {
@extend %with-deny-color-icon, %as-pseudo;
}
%consul-intention-fieldsets .radio-card header span.code::before {
@extend %with-deny-color-icon, %as-pseudo;
margin-right: 5px;
}

View File

@ -1,4 +0,0 @@
@import './consul-intention-list/index';
.consul-intention-list {
@extend %consul-intention-list;
}

View File

@ -1,19 +0,0 @@
@import './skin';
@import './layout';
%consul-intention-list td.source,
%consul-intention-list td.destination {
@extend %tbody-th;
}
%consul-intention-list td strong {
@extend %pill-700;
}
%consul-intention-list td.intent-allow strong {
@extend %pill-allow;
}
%consul-intention-list td.intent-deny strong {
@extend %pill-deny;
}
%consul-intention-list td.intent-l7-rules strong {
@extend %pill-l7;
}

View File

@ -1,27 +0,0 @@
%consul-intention-list td {
height: 59px;
}
%consul-intention-list tr > *:nth-child(1) {
width: calc(30% - 50px);
}
%consul-intention-list tr > *:nth-child(2) {
width: 100px;
}
%consul-intention-list tr > *:nth-child(3) {
width: calc(30% - 50px);
}
%consul-intention-list tr > *:nth-child(4) {
width: calc(40% - 220px);
}
%consul-intention-list tr > *:nth-child(5) {
width: 160px;
}
%consul-intention-list tr > *:last-child {
width: 60px;
}
@media #{$--lt-horizontal-nav} {
%consul-intention-list tr > :not(.source):not(.destination):not(.intent) {
display: none;
}
}

View File

@ -1,3 +0,0 @@
%consul-intention-list td.permissions {
color: $blue-500;
}

View File

@ -1,4 +0,0 @@
@import './consul-intention-permission-list/index';
.consul-intention-permission-list {
@extend %consul-intention-permission-list;
}

View File

@ -1,14 +0,0 @@
@import './skin';
@import './layout';
%consul-intention-permission-list > ul > li {
@extend %list-row, %list-row-detail;
}
%consul-intention-permission-list strong {
@extend %pill-500;
}
%consul-intention-permission-list .intent-allow {
@extend %pill-allow;
}
%consul-intention-permission-list .intent-deny {
@extend %pill-deny;
}

View File

@ -1,3 +0,0 @@
%consul-intention-permission-list strong {
margin-right: 8px;
}

View File

@ -1,9 +0,0 @@
%consul-intention-permission-list dt::before {
@extend %with-glyph-icon;
}
%consul-intention-permission-list dl.route-path dt::before {
content: 'P';
}
%consul-intention-permission-list dl.route-header dt::before {
content: 'H';
}

View File

@ -1,7 +1,7 @@
%flash-message { %flash-message {
display: flex; display: flex;
position: relative; position: relative;
z-index: 2; z-index: 6;
justify-content: center; justify-content: center;
margin: 0 15%; margin: 0 15%;
} }

View File

@ -13,7 +13,7 @@ label span {
%main-content form { %main-content form {
@extend %form; @extend %form;
} }
%form span.label { span.label {
@extend %form-element-label; @extend %form-element-label;
} }
%form table, %form table,

View File

@ -6,13 +6,13 @@
display: inline-flex; display: inline-flex;
flex-wrap: nowrap; flex-wrap: nowrap;
} }
%icon-definition > dt { %icon-definition > * {
align-self: center; align-self: center;
} }
%icon-definition > dd {
white-space: nowrap;
margin-left: 4px;
}
%icon-definition > dt > * { %icon-definition > dt > * {
display: none; display: none;
} }
%icon-definition > dd {
margin-left: 4px;
white-space: nowrap;
}

View File

@ -1,13 +1,21 @@
.list-collection { .list-collection {
@extend %list-collection; @extend %list-collection;
} }
%list-collection { .list-collection-scroll-virtual {
@extend %list-collection-scroll-virtual;
}
%list-collection-scroll-virtual {
height: 500px; height: 500px;
position: relative; position: relative;
} }
%list-collection > ul { %list-collection > ul {
border-top: 1px solid $gray-200; border-top: 1px solid $gray-200;
overflow-x: hidden !important; }
%list-collection > ul > li {
position: relative;
}
%list-collection-scroll-virtual > ul {
overflow-x: hidden;
} }
%list-collection > ul > li:nth-child(2) .with-feedback p { %list-collection > ul > li:nth-child(2) .with-feedback p {
bottom: auto; bottom: auto;

View File

@ -1,20 +1,13 @@
import { get, set, computed } from '@ember/object'; import { get, set } from '@ember/object';
import Changeset from 'ember-changeset'; import { Changeset as createChangeset } from 'ember-changeset';
import Changeset from 'consul-ui/utils/form/changeset';
import lookupValidator from 'ember-changeset-validations'; import lookupValidator from 'ember-changeset-validations';
// Keep these here for now so forms are easy to make // Keep these here for now so forms are easy to make
// TODO: Probably move this to utils/form/parse-element-name // TODO: Probably move this to utils/form/parse-element-name
import parseElementName from 'consul-ui/utils/get-form-name-property'; import parseElementName from 'consul-ui/utils/get-form-name-property';
// TODO: Currently supporting ember-data nicely like this
// Unfortunately since post-ember 2.18, the only way to get this to work
// is to hang stuff off the prototype (which then makes it available everywhere)
// we should either try and figure out another way of doing this, or move this code
// somewhere where it is more 'global' like an initializer
Changeset.prototype.isSaving = computed('data.isSaving', function() {
return this.data.isSaving;
});
const defaultChangeset = function(data, validators) { const defaultChangeset = function(data, validators) {
return new Changeset(data, lookupValidator(validators), validators); return createChangeset(data, lookupValidator(validators), validators, { changeset: Changeset });
}; };
/** /**
* Form builder/Form factory * Form builder/Form factory

View File

@ -0,0 +1,38 @@
import { get } from '@ember/object';
import { EmberChangeset as Changeset } from 'ember-changeset';
const CHANGES = '_changes';
export default class extends Changeset {
pushObject(prop, value) {
let val;
if (typeof get(this, `${CHANGES}.${prop}`) === 'undefined') {
val = get(this, `data.${prop}`);
if (!val) {
val = [];
} else {
val = val.toArray();
}
} else {
val = this.get(prop).slice(0);
}
val.push(value);
this.set(`${prop}`, val);
}
removeObject(prop, value) {
let val;
if (typeof get(this, `${CHANGES}.${prop}`) === 'undefined') {
val = get(this, `data.${prop}`);
if (typeof val === 'undefined') {
val = [];
} else {
val = val.toArray();
}
} else {
val = this.get(prop).slice(0);
}
const pos = val.indexOf(value);
if (pos !== -1) {
val.splice(pos, 1);
}
this.set(`${prop}`, val);
}
}

View File

@ -0,0 +1,8 @@
import { validatePresence } from 'ember-changeset-validations/validators';
import validateSometimes from 'ember-changeset-conditional-validations/validators/sometimes';
export default schema => ({
Name: [validatePresence(true)],
Value: validateSometimes([validatePresence(true)], function() {
return this.get('HeaderType') !== 'Present';
}),
});

View File

@ -0,0 +1,29 @@
import {
validateInclusion,
validatePresence,
validateFormat,
} from 'ember-changeset-validations/validators';
import validateSometimes from 'ember-changeset-conditional-validations/validators/sometimes';
const name = 'intention-permission';
export default schema => ({
'*': validateSometimes([validatePresence(true)], function() {
const methods = this.get('HTTP.Methods') || [];
const headers = this.get('HTTP.Header') || [];
const pathType = this.get('HTTP.PathType') || 'NoPath';
const path = this.get('HTTP.Path') || '';
const isValid = [
methods.length !== 0,
headers.length !== 0,
pathType !== 'NoPath' && path !== '',
].includes(true);
return !isValid;
}),
Action: [validateInclusion({ in: schema[name].Action.allowedValues })],
HTTP: {
Path: validateSometimes([validateFormat({ regex: /^\// })], function() {
const pathType = this.get('HTTP.PathType');
return typeof pathType !== 'undefined' && pathType !== 'NoPath';
}),
},
});

View File

@ -1,6 +1,19 @@
import { validatePresence, validateLength } from 'ember-changeset-validations/validators'; import { validatePresence, validateLength } from 'ember-changeset-validations/validators';
import validateSometimes from 'ember-changeset-conditional-validations/validators/sometimes';
export default { export default {
'*': validateSometimes([validatePresence(true)], function() {
const action = this.get('Action') || '';
const permissions = this.get('Permissions') || [];
if (action === '' && permissions.length === 0) {
return true;
}
return false;
}),
SourceName: [validatePresence(true), validateLength({ min: 1 })], SourceName: [validatePresence(true), validateLength({ min: 1 })],
DestinationName: [validatePresence(true), validateLength({ min: 1 })], DestinationName: [validatePresence(true), validateLength({ min: 1 })],
Action: validatePresence(true), Permissions: [
validateSometimes([validateLength({ min: 1 })], function(changes, content) {
return !this.get('Action');
}),
],
}; };

View File

@ -68,7 +68,8 @@
"css.escape": "^1.5.1", "css.escape": "^1.5.1",
"dart-sass": "^1.25.0", "dart-sass": "^1.25.0",
"ember-auto-import": "^1.5.3", "ember-auto-import": "^1.5.3",
"ember-changeset-validations": "^3.0.2", "ember-changeset-conditional-validations": "^0.6.0",
"ember-changeset-validations": "^3.9.0",
"ember-cli": "~3.20.2", "ember-cli": "~3.20.2",
"ember-cli-app-version": "^3.2.0", "ember-cli-app-version": "^3.2.0",
"ember-cli-autoprefixer": "^0.8.1", "ember-cli-autoprefixer": "^0.8.1",

View File

@ -33,7 +33,7 @@ Feature: dc / intentions / create: Intention Create
# Specifically set deny # Specifically set deny
And I click "[value=deny]" And I click "[value=deny]"
And I submit And I submit
Then a POST request was made to "/v1/connect/intentions?dc=datacenter" from yaml Then a PUT request was made to "/v1/connect/intentions/exact?source=default%2Fweb&destination=default%2Fdb&dc=datacenter" from yaml
--- ---
body: body:
SourceName: web SourceName: web

View File

@ -2,13 +2,16 @@
Feature: dc / intentions / deleting: Deleting items with confirmations, success and error notifications Feature: dc / intentions / deleting: Deleting items with confirmations, success and error notifications
Background: Background:
Given 1 datacenter model with the value "datacenter" Given 1 datacenter model with the value "datacenter"
Scenario: Deleting a intention model from the intention listing page And 1 intention model from yaml
Given 1 intention model from yaml
--- ---
SourceNS: default
SourceName: name SourceName: name
DestinationNS: default
DestinationName: destination
ID: ee52203d-989f-4f7a-ab5a-2bef004164ca ID: ee52203d-989f-4f7a-ab5a-2bef004164ca
Meta: ~ Meta: ~
--- ---
Scenario: Deleting a intention model from the intention listing page
When I visit the intentions page for yaml When I visit the intentions page for yaml
--- ---
dc: datacenter dc: datacenter
@ -16,7 +19,7 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
And I click actions on the intentions And I click actions on the intentions
And I click delete on the intentions And I click delete on the intentions
And I click confirmDelete on the intentions And I click confirmDelete on the intentions
Then a DELETE request was made to "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" Then a DELETE request was made to "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=datacenter"
And "[data-notification]" has the "notification-delete" class And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class And "[data-notification]" has the "success" class
Scenario: Deleting an intention from the intention detail page Scenario: Deleting an intention from the intention detail page
@ -27,7 +30,7 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
--- ---
And I click delete And I click delete
And I click confirmDelete And I click confirmDelete
Then a DELETE request was made to "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" Then a DELETE request was made to "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=datacenter"
And "[data-notification]" has the "notification-delete" class And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class And "[data-notification]" has the "success" class
Scenario: Deleting an intention from the intention detail page and getting an error Scenario: Deleting an intention from the intention detail page and getting an error
@ -36,7 +39,7 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
dc: datacenter dc: datacenter
intention: ee52203d-989f-4f7a-ab5a-2bef004164ca intention: ee52203d-989f-4f7a-ab5a-2bef004164ca
--- ---
Given the url "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" responds with a 500 status Given the url "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=datacenter" responds with a 500 status
And I click delete And I click delete
And I click confirmDelete And I click confirmDelete
And "[data-notification]" has the "notification-update" class And "[data-notification]" has the "notification-update" class
@ -47,7 +50,7 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
dc: datacenter dc: datacenter
intention: ee52203d-989f-4f7a-ab5a-2bef004164ca intention: ee52203d-989f-4f7a-ab5a-2bef004164ca
--- ---
Given the url "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" responds with from yaml Given the url "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=datacenter" responds with from yaml
--- ---
status: 500 status: 500
body: "duplicate intention found:" body: "duplicate intention found:"

View File

@ -4,6 +4,10 @@ Feature: dc / intentions / update: Intention Update
Given 1 datacenter model with the value "datacenter" Given 1 datacenter model with the value "datacenter"
And 1 intention model from yaml And 1 intention model from yaml
--- ---
SourceNS: default
SourceName: web
DestinationNS: default
DestinationName: db
ID: intention-id ID: intention-id
--- ---
When I visit the intention page for yaml When I visit the intention page for yaml
@ -20,7 +24,7 @@ Feature: dc / intentions / update: Intention Update
--- ---
And I click "[value=[Action]]" And I click "[value=[Action]]"
And I submit And I submit
Then a PUT request was made to "/v1/connect/intentions/intention-id?dc=datacenter" with the body from yaml Then a PUT request was made to "/v1/connect/intentions/exact?source=default%2Fweb&destination=default%2Fdb&dc=datacenter" from yaml
--- ---
Description: [Description] Description: [Description]
Action: [Action] Action: [Action]
@ -35,7 +39,7 @@ Feature: dc / intentions / update: Intention Update
| Desc | allow | | Desc | allow |
------------------------------ ------------------------------
Scenario: There was an error saving the intention Scenario: There was an error saving the intention
Given the url "/v1/connect/intentions/intention-id" responds with a 500 status Given the url "/v1/connect/intentions/exact?source=default%2Fweb&destination=default%2Fdb&dc=datacenter" responds with a 500 status
And I submit And I submit
Then the url should be /datacenter/intentions/intention-id Then the url should be /datacenter/intentions/intention-id
Then "[data-notification]" has the "notification-update" class Then "[data-notification]" has the "notification-update" class

View File

@ -15,6 +15,11 @@ Feature: dc / services / show / intentions: Intentions per service
- ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f0 - ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f0
Action: allow Action: allow
Meta: ~ Meta: ~
SourceNS: default
SourceName: name
DestinationNS: default
DestinationName: destination
- ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f1 - ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f1
Action: deny Action: deny
Meta: ~ Meta: ~
@ -37,6 +42,6 @@ Feature: dc / services / show / intentions: Intentions per service
And I click actions on the intentions And I click actions on the intentions
And I click delete on the intentions And I click delete on the intentions
And I click confirmDelete on the intentions And I click confirmDelete on the intentions
Then a DELETE request was made to "/v1/connect/intentions/755b72bd-f5ab-4c92-90cc-bed0e7d8e9f0?dc=dc1" Then a DELETE request was made to "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=dc1"
And "[data-notification]" has the "notification-delete" class And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class And "[data-notification]" has the "success" class

View File

@ -3,7 +3,6 @@ import { setupTest } from 'ember-qunit';
module('Integration | Adapter | intention', function(hooks) { module('Integration | Adapter | intention', function(hooks) {
setupTest(hooks); setupTest(hooks);
const dc = 'dc-1'; const dc = 'dc-1';
const legacyId = 'intention-name';
const id = 'SourceNS:SourceName:DestinationNS:DestinationName'; const id = 'SourceNS:SourceName:DestinationNS:DestinationName';
test('requestForQuery returns the correct url', function(assert) { test('requestForQuery returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention'); const adapter = this.owner.lookup('adapter:intention');
@ -38,14 +37,17 @@ module('Integration | Adapter | intention', function(hooks) {
test('requestForCreateRecord returns the correct url', function(assert) { test('requestForCreateRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention'); const adapter = this.owner.lookup('adapter:intention');
const client = this.owner.lookup('service:client/http'); const client = this.owner.lookup('service:client/http');
const expected = `POST /v1/connect/intentions?dc=${dc}`; const expected = `PUT /v1/connect/intentions/exact?source=SourceNS%2FSourceName&destination=DestinationNS%2FDestinationName&dc=${dc}`;
const actual = adapter const actual = adapter
.requestForCreateRecord( .requestForCreateRecord(
client.url, client.url,
{}, {},
{ {
Datacenter: dc, Datacenter: dc,
ID: id, SourceNS: 'SourceNS',
SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
} }
) )
.split('\n')[0]; .split('\n')[0];
@ -54,15 +56,17 @@ module('Integration | Adapter | intention', function(hooks) {
test('requestForUpdateRecord returns the correct url', function(assert) { test('requestForUpdateRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention'); const adapter = this.owner.lookup('adapter:intention');
const client = this.owner.lookup('service:client/http'); const client = this.owner.lookup('service:client/http');
const expected = `PUT /v1/connect/intentions/${legacyId}?dc=${dc}`; const expected = `PUT /v1/connect/intentions/exact?source=SourceNS%2FSourceName&destination=DestinationNS%2FDestinationName&dc=${dc}`;
const actual = adapter const actual = adapter
.requestForUpdateRecord( .requestForUpdateRecord(
client.url, client.url,
{}, {},
{ {
Datacenter: dc, Datacenter: dc,
ID: id, SourceNS: 'SourceNS',
LegacyID: legacyId, SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
} }
) )
.split('\n')[0]; .split('\n')[0];
@ -71,15 +75,17 @@ module('Integration | Adapter | intention', function(hooks) {
test('requestForDeleteRecord returns the correct url', function(assert) { test('requestForDeleteRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention'); const adapter = this.owner.lookup('adapter:intention');
const client = this.owner.lookup('service:client/http'); const client = this.owner.lookup('service:client/http');
const expected = `DELETE /v1/connect/intentions/${legacyId}?dc=${dc}`; const expected = `DELETE /v1/connect/intentions/exact?source=SourceNS%2FSourceName&destination=DestinationNS%2FDestinationName&dc=${dc}`;
const actual = adapter const actual = adapter
.requestForDeleteRecord( .requestForDeleteRecord(
client.url, client.url,
{}, {},
{ {
Datacenter: dc, Datacenter: dc,
ID: id, SourceNS: 'SourceNS',
LegacyID: legacyId, SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
} }
) )
.split('\n')[0]; .split('\n')[0];

View File

@ -1487,6 +1487,14 @@
"@glimmer/env" "^0.1.7" "@glimmer/env" "^0.1.7"
"@glimmer/validator" "^0.44.0" "@glimmer/validator" "^0.44.0"
"@glimmer/tracking@^1.0.1":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@glimmer/tracking/-/tracking-1.0.2.tgz#4fe9ca89e3f4a2ae8e37c8bd8e4ea0d886d9abbf"
integrity sha512-9Vp04TM2IDTShGFdxccfvnmcaj4NwqLrwbOXm4iju5KL/WkeB8mqoCSLtO3kUg+80DqU0pKE8tR460lQP8CutA==
dependencies:
"@glimmer/env" "^0.1.7"
"@glimmer/validator" "^0.44.0"
"@glimmer/util@^0.44.0": "@glimmer/util@^0.44.0":
version "0.44.0" version "0.44.0"
resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.44.0.tgz#45df98d73812440206ae7bda87cfe04aaae21ed9" resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.44.0.tgz#45df98d73812440206ae7bda87cfe04aaae21ed9"
@ -1520,14 +1528,14 @@
js-yaml "^3.13.1" js-yaml "^3.13.1"
"@hashicorp/consul-api-double@^5.0.0": "@hashicorp/consul-api-double@^5.0.0":
version "5.0.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.0.1.tgz#07880706ab26cc242332cef86b2c03b3b4ec4e56" resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.1.tgz#a411139fa1afa0dfaf1b9973f21275530a39939b"
integrity sha512-uptXq/XTGL5uzGqvwRqC0tzHKCJMVAaRMucPxjbMb4r9wOmOdT4Z2BUJD8GDcCSFIWE8hbWeqAlCXRrokZ3wbw== integrity sha512-ASQv2I8iprnFmpAvbHEoKE8MXTpOxdeBan6nkgobmz4OyvMcqu/h29CGEXZ9j63NX6+nxmE84nV5yAqADRubGQ==
"@hashicorp/ember-cli-api-double@^3.1.0": "@hashicorp/ember-cli-api-double@^3.1.0":
version "3.1.1" version "3.1.2"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-3.1.1.tgz#ba16a514131ce409054d1ae1a71483941d937d37" resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-3.1.2.tgz#0eaee116da1431ed63e55eea9ff9c28028cf9f8c"
integrity sha512-VLvV/m+Sx+vG+tHK1FeVjiBXwt8KcIWqgFavglrEBTkVTA2o7uP0xN9nKOJjos49KK+h1K3fCwMK5ltz7Kt97w== integrity sha512-4j4JxIHBeo5KjTfcEAIrzjtiWBTxPzTTBMiigNgKAIWAtO6Hz58LQ6kDJl8MN52kSq2uSBlFJpp6aQhfwJaPtw==
dependencies: dependencies:
"@hashicorp/api-double" "^1.6.1" "@hashicorp/api-double" "^1.6.1"
array-range "^1.0.1" array-range "^1.0.1"
@ -2632,6 +2640,11 @@ babel-plugin-htmlbars-inline-precompile@^3.0.1:
resolved "https://registry.yarnpkg.com/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-3.1.0.tgz#85085b50385277f2b331ebd54e22fa91aadc24e8" resolved "https://registry.yarnpkg.com/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-3.1.0.tgz#85085b50385277f2b331ebd54e22fa91aadc24e8"
integrity sha512-ar6c4YVX6OV7Dzpq7xRyllQrHwVEzJf41qysYULnD6tu6TS+y1FxT2VcEvMC6b9Rq9xoHMzvB79HO3av89JCGg== integrity sha512-ar6c4YVX6OV7Dzpq7xRyllQrHwVEzJf41qysYULnD6tu6TS+y1FxT2VcEvMC6b9Rq9xoHMzvB79HO3av89JCGg==
babel-plugin-htmlbars-inline-precompile@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-3.2.0.tgz#c4882ea875d0f5683f0d91c1f72e29a4f14b5606"
integrity sha512-IUeZmgs9tMUGXYu1vfke5I18yYJFldFGdNFQOWslXTnDWXzpwPih7QFduUqvT+awDpDuNtXpdt5JAf43Q1Hhzg==
babel-plugin-htmlbars-inline-precompile@^4.1.0: babel-plugin-htmlbars-inline-precompile@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-4.1.0.tgz#11796422e65d900a968481fa3fb37e0425c928dd" resolved "https://registry.yarnpkg.com/babel-plugin-htmlbars-inline-precompile/-/babel-plugin-htmlbars-inline-precompile-4.1.0.tgz#11796422e65d900a968481fa3fb37e0425c928dd"
@ -4151,9 +4164,9 @@ caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.300010
integrity sha512-PFTw9UyVfbkcMEFs82q8XVlRayj7HKvnhu5BLcmjGpv+SNyiWasCcWXPGJuO0rK0dhLRDJmtZcJ+LHUfypbw1w== integrity sha512-PFTw9UyVfbkcMEFs82q8XVlRayj7HKvnhu5BLcmjGpv+SNyiWasCcWXPGJuO0rK0dhLRDJmtZcJ+LHUfypbw1w==
caniuse-lite@^1.0.30000844: caniuse-lite@^1.0.30000844:
version "1.0.30001125" version "1.0.30001137"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001125.tgz#2a1a51ee045a0a2207474b086f628c34725e997b" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001137.tgz#6f0127b1d3788742561a25af3607a17fc778b803"
integrity sha512-9f+r7BW8Qli917mU3j0fUaTweT3f3vnX/Lcs+1C73V+RADmFme+Ih0Br8vONQi3X0lseOe6ZHfsZLCA8MSjxUA== integrity sha512-54xKQZTqZrKVHmVz0+UvdZR6kQc7pJDgfhsMYDG19ID1BWoNnDMFm5Q3uSBSU401pBvKYMsHAt9qhEDcxmk8aw==
capture-exit@^2.0.0: capture-exit@^2.0.0:
version "2.0.0" version "2.0.0"
@ -5204,9 +5217,9 @@ electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.488:
integrity sha512-EOZuaDT3L1sCIMAVN5J0nGuGWVq5dThrdl0d8XeDYf4MOzbXqZ19OLKesN8TZj0RxtpYjqHpiw/fR6BKWdMwYA== integrity sha512-EOZuaDT3L1sCIMAVN5J0nGuGWVq5dThrdl0d8XeDYf4MOzbXqZ19OLKesN8TZj0RxtpYjqHpiw/fR6BKWdMwYA==
electron-to-chromium@^1.3.47: electron-to-chromium@^1.3.47:
version "1.3.565" version "1.3.575"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.565.tgz#8511797ab2b66b767e1aef4eb17d636bf01a2c72" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.575.tgz#57065cfad7b977a817dba47b28e7eb4dbce3fc37"
integrity sha512-me5dGlHFd8Q7mKhqbWRLIYnKjw4i0fO6hmW0JBxa7tM87fBfNEjWokRnDF7V+Qme/9IYpwhfMn+soWs40tXWqg== integrity sha512-031VrjcilnE8bXivDGhEeuGjMZrjTAeyAKm3XWPY9SvGYE6Hn8003gCqoNszFu6lh1v0gDx5hrM0VE1cPSMUkQ==
elliptic@^6.0.0, elliptic@^6.5.2: elliptic@^6.0.0, elliptic@^6.5.2:
version "6.5.3" version "6.5.3"
@ -5286,26 +5299,33 @@ ember-basic-dropdown@^3.0.3:
ember-maybe-in-element "^0.4.0" ember-maybe-in-element "^0.4.0"
ember-truth-helpers "2.1.0" ember-truth-helpers "2.1.0"
ember-changeset-validations@^3.0.2: ember-changeset-conditional-validations@^0.6.0:
version "3.7.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/ember-changeset-validations/-/ember-changeset-validations-3.7.0.tgz#74875705128f56ffe22bf54fe6856838dc9caa7d" resolved "https://registry.yarnpkg.com/ember-changeset-conditional-validations/-/ember-changeset-conditional-validations-0.6.0.tgz#78369ad3af0aea338e00a9bdf1b622fb512d9a00"
integrity sha512-E4Um4IV5UO72FdT1GWcI8EbNOHE7eS16XFokk6UaKpXs3MLuI1Ev6Zb8Q9Z20g/9/IieBI4XddnGd+1FpXz6aA== integrity sha512-U9TZFhhLC+5XRqcI5sNfJwGVcVZvXJxwrRQrrTYLImHe/+tcgP/TagE0f0DBrgWV2u3lsztGHGwGUs86uc65rg==
dependencies: dependencies:
ember-changeset "^3.7.0" ember-cli-babel "^6.16.0"
ember-changeset-validations@^3.9.0:
version "3.9.1"
resolved "https://registry.yarnpkg.com/ember-changeset-validations/-/ember-changeset-validations-3.9.1.tgz#5f44f56ca9a55ac079f667f8cbead1dfeb85a21d"
integrity sha512-aTufViqh4zx8WNjiuxuQNSfYaDLDdXl7mQ6g18z2Wma55kEmGymxaltM3lrGgXK+IlWoJvINrNBd6i6AYRxaYA==
dependencies:
ember-changeset "^3.9.1"
ember-cli-babel "^7.8.0" ember-cli-babel "^7.8.0"
ember-cli-htmlbars "^4.0.5" ember-cli-htmlbars "^4.0.5"
ember-get-config "^0.2.4" ember-get-config "^0.2.4"
ember-validators "^2.0.0" ember-validators "^2.0.0"
ember-changeset@^3.7.0: ember-changeset@^3.9.1:
version "3.7.1" version "3.9.1"
resolved "https://registry.yarnpkg.com/ember-changeset/-/ember-changeset-3.7.1.tgz#5826e2bb7151f85494208aedac25a00400bddf13" resolved "https://registry.yarnpkg.com/ember-changeset/-/ember-changeset-3.9.1.tgz#53b50be95364a71f38e68a01c33eba12808cd8a4"
integrity sha512-vYkF9LHoFwQJb9yytfbA9J888a82/4i+NQUeYtyuLtoD4Zty6Z1NzZ5eWwVU/RbsbK45x85T5FKmsxSS1/1cgw== integrity sha512-Ntf0fITb2klRZF+5s5xbBQ6HNuSn1IbwppyZPU8v7oh26QOlfjyxYE7QNWM3nZnybHkCrF1iSqN3s8xj3OoFCg==
dependencies: dependencies:
"@glimmer/tracking" "^1.0.0" "@glimmer/tracking" "^1.0.1"
ember-auto-import "^1.5.2" ember-auto-import "^1.5.2"
ember-cli-babel "^7.19.0" ember-cli-babel "^7.19.0"
validated-changeset "~0.7.1" validated-changeset "~0.9.1"
ember-cli-app-version@^3.2.0: ember-cli-app-version@^3.2.0:
version "3.2.0" version "3.2.0"
@ -5328,7 +5348,7 @@ ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0:
resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.0.tgz#de3baedd093163b6c2461f95964888c1676325ac" resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.0.tgz#de3baedd093163b6c2461f95964888c1676325ac"
integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA== integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA==
ember-cli-babel@7: ember-cli-babel@7, ember-cli-babel@^7.8.0:
version "7.22.1" version "7.22.1"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.22.1.tgz#cad28b89cf0e184c93b863d09bc5ba4ce1d2e453" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.22.1.tgz#cad28b89cf0e184c93b863d09bc5ba4ce1d2e453"
integrity sha512-kCT8WbC1AYFtyOpU23ESm22a+gL6fWv8Nzwe8QFQ5u0piJzM9MEudfbjADEaoyKTrjMQTDsrWwEf3yjggDsOng== integrity sha512-kCT8WbC1AYFtyOpU23ESm22a+gL6fWv8Nzwe8QFQ5u0piJzM9MEudfbjADEaoyKTrjMQTDsrWwEf3yjggDsOng==
@ -5379,7 +5399,7 @@ ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.11.0,
ember-cli-version-checker "^2.1.2" ember-cli-version-checker "^2.1.2"
semver "^5.5.0" semver "^5.5.0"
ember-cli-babel@^7.1.0, ember-cli-babel@^7.1.2, ember-cli-babel@^7.1.3, ember-cli-babel@^7.10.0, ember-cli-babel@^7.11.0, ember-cli-babel@^7.11.1, ember-cli-babel@^7.12.0, ember-cli-babel@^7.13.0, ember-cli-babel@^7.17.2, ember-cli-babel@^7.18.0, ember-cli-babel@^7.19.0, ember-cli-babel@^7.20.0, ember-cli-babel@^7.20.5, ember-cli-babel@^7.21.0, ember-cli-babel@^7.7.3, ember-cli-babel@^7.8.0: ember-cli-babel@^7.1.0, ember-cli-babel@^7.1.2, ember-cli-babel@^7.1.3, ember-cli-babel@^7.10.0, ember-cli-babel@^7.11.0, ember-cli-babel@^7.11.1, ember-cli-babel@^7.12.0, ember-cli-babel@^7.13.0, ember-cli-babel@^7.17.2, ember-cli-babel@^7.18.0, ember-cli-babel@^7.19.0, ember-cli-babel@^7.20.0, ember-cli-babel@^7.20.5, ember-cli-babel@^7.21.0, ember-cli-babel@^7.7.3:
version "7.21.0" version "7.21.0"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.21.0.tgz#c79e888876aee87dfc3260aee7cb580b74264bbc" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.21.0.tgz#c79e888876aee87dfc3260aee7cb580b74264bbc"
integrity sha512-jHVi9melAibo0DrAG3GAxid+29xEyjBoU53652B4qcu3Xp58feZGTH/JGXovH7TjvbeNn65zgNyoV3bk1onULw== integrity sha512-jHVi9melAibo0DrAG3GAxid+29xEyjBoU53652B4qcu3Xp58feZGTH/JGXovH7TjvbeNn65zgNyoV3bk1onULw==
@ -5505,7 +5525,27 @@ ember-cli-htmlbars@^3.0.0, ember-cli-htmlbars@^3.0.1:
json-stable-stringify "^1.0.1" json-stable-stringify "^1.0.1"
strip-bom "^3.0.0" strip-bom "^3.0.0"
ember-cli-htmlbars@^4.0.5, ember-cli-htmlbars@^4.2.0, ember-cli-htmlbars@^4.2.2, ember-cli-htmlbars@^4.3.1: ember-cli-htmlbars@^4.0.5:
version "4.4.0"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.4.0.tgz#7ca17d5ca8f7550984346d9e6e93da0c3323f8d9"
integrity sha512-ohgctqk7dXIZR4TgN0xRoUYltWhghFJgqmtuswQTpZ7p74RxI9PKx+E8WV/95mGcPzraesvMNBg5utQNvcqgNg==
dependencies:
"@ember/edition-utils" "^1.2.0"
babel-plugin-htmlbars-inline-precompile "^3.2.0"
broccoli-debug "^0.6.5"
broccoli-persistent-filter "^2.3.1"
broccoli-plugin "^3.1.0"
common-tags "^1.8.0"
ember-cli-babel-plugin-helpers "^1.1.0"
fs-tree-diff "^2.0.1"
hash-for-dep "^1.5.1"
heimdalljs-logger "^0.1.10"
json-stable-stringify "^1.0.1"
semver "^6.3.0"
strip-bom "^4.0.0"
walk-sync "^2.0.2"
ember-cli-htmlbars@^4.2.0, ember-cli-htmlbars@^4.2.2, ember-cli-htmlbars@^4.3.1:
version "4.3.1" version "4.3.1"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.3.1.tgz#4af8adc21ab3c4953f768956b7f7d207782cb175" resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.3.1.tgz#4af8adc21ab3c4953f768956b7f7d207782cb175"
integrity sha512-CW6AY/yzjeVqoRtItOKj3hcYzc5dWPRETmeCzr2Iqjt5vxiVtpl0z5VTqHqIlT5fsFx6sGWBQXNHIe+ivYsxXQ== integrity sha512-CW6AY/yzjeVqoRtItOKj3hcYzc5dWPRETmeCzr2Iqjt5vxiVtpl0z5VTqHqIlT5fsFx6sGWBQXNHIe+ivYsxXQ==
@ -12957,10 +12997,10 @@ validate-npm-package-name@^3.0.0:
dependencies: dependencies:
builtins "^1.0.3" builtins "^1.0.3"
validated-changeset@~0.7.1: validated-changeset@~0.9.1:
version "0.7.1" version "0.9.1"
resolved "https://registry.yarnpkg.com/validated-changeset/-/validated-changeset-0.7.1.tgz#cb2c11c93d5acfb2286e5bfca07f4a516f83c844" resolved "https://registry.yarnpkg.com/validated-changeset/-/validated-changeset-0.9.1.tgz#bb4773c7c5392dcc7ecfbc8ccc10c7fef2840d42"
integrity sha512-BbFK98Cp7WunEwLOW/oAi6qDZZFBOLkae0q5RZ3ne8ZkwB1sskOYfF5IqQhqubwxRb4emMhA2UgN5rdfOaZxXQ== integrity sha512-gEMvF+GN8ECLndHw5Ehc9ckXMgM+RRDK5+lCx2hGX9Qie1q68ixLEtbNXbPPhV4JHq6d7krVfTG+sEQ3X6zoHA==
vary@~1.1.2: vary@~1.1.2:
version "1.1.2" version "1.1.2"