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

View File

@ -92,7 +92,9 @@
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
{{#if create}}
<em>For the destination, you may choose any namespace for which you have access.</em>
{{/if}}
</label>
{{/if}}
</fieldset>
@ -112,12 +114,17 @@
header="Deny"
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
class={{concat '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}}
@name="Action"
as |radio|>
@ -131,13 +138,10 @@
{{/each}}
</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>
{{#if (not item.Legacy)}}
<fieldset>
{{#if (eq (or item.Action '') '')}}
<fieldset class="permissions">
<button type="button" onclick={{action (mut shouldShowPermissionForm) true}}>Add permission</button>
<h2>Permissions</h2>
{{#if (gt item.Permissions.length 0) }}
<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>
</p>
</div>
<ConsulIntentionPermissionList @items={{item.Permissions}} />
<ConsulIntentionPermissionList
@items={{item.Permissions}}
@onclick={{queue (action (mut permission)) (action (mut shouldShowPermissionForm) true)}}
@ondelete={{action 'delete' 'Permissions' item}}
/>
{{else}}
<EmptyState>
<BlockSlot @name="header">
<h3>
Add permissions via CLI
No permissions yet
</h3>
</BlockSlot>
<BlockSlot @name="body">
<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>
</BlockSlot>
<BlockSlot @name="actions">
@ -173,4 +181,47 @@
{{/if}}
</fieldset>
{{/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>

View File

@ -2,6 +2,9 @@ import Component from '@ember/component';
export default Component.extend({
tagName: '',
shouldShowPermissionForm: false,
actions: {
createNewLabel: function(template, term) {
return template.replace(/{{term}}/g, term);
@ -9,5 +12,17 @@ export default Component.extend({
isUnique: function(items, 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)}}
<div class="consul-intention-permission-list">
<ul>
{{#each items as |item|}}
<li>
<ListCollection
class="consul-intention-permission-list{{if (not onclick) ' readonly'}}"
@scroll="native"
@items={{items}}
@cellHeight={{42}}
as |item|>
<BlockSlot @name="details">
<div onclick={{action (optional onclick) item}}>
<strong class={{concat 'intent-' item.Action}}>{{item.Action}}</strong>
{{#if item.Http.Path}}
<dl class="route-path">
{{#if (gt item.HTTP.Methods.length 0)}}
<dl class="permission-methods">
<dt>
<Tooltip>
{{item.Http.PathType}}
Methods
</Tooltip>
</dt>
<dd>
{{item.Http.Path}}
{{#each item.HTTP.Methods as |item|}}
{{item}}
{{/each}}
</dd>
</dl>
{{/if}}
{{#each item.Http.Header as |item|}}
<dl class="route-header">
{{#if item.HTTP.Path}}
<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>
<Tooltip>
Header
</Tooltip>
</dt>
<dd>
{{item.Name}} {{route-match item}}
{{item.Name}} {{route-match item}}
</dd>
</dl>
{{/each}}
</li>
{{/each}}
</ul>
</div>
</div>
</BlockSlot>
{{#if onclick}}
<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}}

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>
</div>
<ConsulIntentionPermissionList @items={{item.Permissions}} @readonly={{true}} />
<ConsulIntentionPermissionList
@items={{item.Permissions}}
/>
{{/if}}
</div>

View File

@ -1,34 +1,69 @@
{{on-window 'resize' (action "resize") }}
{{yield}}
<EmberNativeScrollable
@tagName="ul"
@content-size={{_contentSize}}
@scroll-left={{_scrollLeft}}
@scroll-top={{_scrollTop}}
@scrollChange={{action "scrollChange"}}
@clientSizeChange={{action "clientSizeChange"}}
<div
class="list-collection list-collection-scroll-{{scroll}}"
style={{{style}}}
id={{guid}}
...attributes
>
<li></li>
{{~#each _cells as |cell|~}}
<li
data-test-list-row
onclick={{action 'click'}} style={{{cell.style}}}
class={{if
(compute (action (or linkable (noop)) cell.item))
'linkable'
}}
>
<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>
<YieldSlot @name="actions"
@params={{
block-params (component 'more-popover-menu' expanded=(if (eq checked cell.index) true false) onchange=(action "change" cell.index))
{{yield}}
{{#if (eq scroll 'virtual')}}
{{on-window 'resize' (action "resize") }}
<EmberNativeScrollable
@tagName="ul"
@content-size={{_contentSize}}
@scroll-left={{_scrollLeft}}
@scroll-top={{_scrollTop}}
@scrollChange={{action "scrollChange"}}
@clientSizeChange={{action "clientSizeChange"}}
>
<li></li>
{{~#each _cells as |cell|~}}
<li
data-test-list-row
onclick={{action 'click'}} style={{{cell.style}}}
class={{if
(compute (action (or linkable (noop)) cell.item))
'linkable'
}}
>
<div class="actions">
{{yield cell.item cell.index}}
</div>
</YieldSlot>
</li>
{{~/each~}}
</EmberNativeScrollable>
<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>
<YieldSlot @name="actions"
@params={{
block-params (component 'more-popover-menu' expanded=(if (eq checked cell.index) true false) onchange=(action "change" cell.index))
}}
>
<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, {
dom: service('dom'),
tagName: 'div',
attributeBindings: ['style'],
tagName: '',
height: 500,
cellHeight: 70,
style: style('getStyle'),
classNames: ['list-collection'],
checked: null,
scroll: 'virtual',
init: function() {
this._super(...arguments);
this.columns = [100];
this.guid = this.dom.guid(this);
},
didInsertElement: function() {
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() {
this._super(...arguments);
@ -41,6 +44,9 @@ export default Component.extend(Slotted, {
};
},
getStyle: computed('height', function() {
if (this.scroll !== 'virtual') {
return {};
}
return {
height: get(this, 'height'),
};
@ -49,12 +55,11 @@ export default Component.extend(Slotted, {
resize: function(e) {
// TODO: This top part is very similar to resize in tabular-collection
// see if it make sense to DRY out
const dom = get(this, 'dom');
const $appContent = dom.element('main > div');
const $appContent = this.dom.element('main > div');
if ($appContent) {
const border = 1;
const rect = this.element.getBoundingClientRect();
const $footer = dom.element('footer[role="contentinfo"]');
const rect = this.$element.getBoundingClientRect();
const $footer = this.dom.element('footer[role="contentinfo"]');
const space = rect.top + $footer.clientHeight + border;
const height = e.target.innerHeight - space;
this.set('height', Math.max(0, height));

View File

@ -36,6 +36,7 @@ as |components|}}
</ToggleButton>
<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">
<input type="checkbox" id={{concat 'popover-menu-' guid '-'}} />
{{#each submenus as |sub|}}

View File

@ -32,7 +32,10 @@
role="menuitem"
aria-selected={{if selected 'true'}}
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">
{{yield}}
</YieldSlot>

View File

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

View File

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

View File

@ -1,7 +1,18 @@
import { computed } from '@ember/object';
import { or } from '@ember/object/computed';
import attr from 'ember-data/attr';
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({
Name: attr('string'),
@ -9,6 +20,10 @@ export default Fragment.extend({
Prefix: attr('string'),
Suffix: attr('string'),
Regex: attr('string'),
Present: attr('boolean'),
Invert: attr('boolean'),
Present: attr(), // this is a boolean but we don't want it to automatically be set to false
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 { 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({
PathExact: attr('string'),
PathPrefix: attr('string'),
@ -14,8 +22,8 @@ export default Fragment.extend({
Header: fragmentArray('intention-permission-http-header'),
Methods: array('string'),
Path: or(...pathProps),
PathType: computed(...pathProps, function() {
return pathProps.find(prop => typeof this[prop] === 'string');
Path: or(...schema.PathType.allowedValues),
PathType: computed(...schema.PathType.allowedValues, function() {
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/attributes';
export const schema = {
Action: {
defaultValue: 'allow',
allowedValues: ['allow', 'deny'],
},
};
export default Fragment.extend({
Action: attr('string', { defaultValue: 'allow' }),
Http: fragment('intention-permission-http'),
Action: attr('string', {
defaultValue: schema.Action.defaultValue,
}),
HTTP: fragment('intention-permission-http'),
});

View File

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

View File

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

View File

@ -44,16 +44,24 @@ export default Serializer.extend({
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) {
return this._super(
cb =>
respond((headers, body) => {
body.LegacyID = body.ID;
body.ID = serialized.ID;
return cb(headers, body);
}),
serialized,
data
);
const slugKey = this.slugKey;
const primaryKey = this.primaryKey;
return respond((headers, body) => {
body = data;
body.LegacyID = body.ID;
body.ID = serialized.ID;
return this.fingerprint(primaryKey, slugKey, body.Datacenter)(body);
});
},
});

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 { typeOf } from '@ember/utils';
import { get } from '@ember/object';
import { isChangeset } from 'validated-changeset';
export default Service.extend({
getModelName: function() {
@ -67,6 +68,13 @@ export default Service.extend({
return this.store.createRecord(this.getModelName(), obj);
},
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();
},
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 { PRIMARY_KEY } from 'consul-ui/models/intention';
const modelName = 'intention';
@ -15,6 +16,19 @@ export default RepositoryService.extend({
...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 = {}) {
const query = {
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/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,
a.type-create {
@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 {
display: flex;
position: relative;
z-index: 2;
z-index: 6;
justify-content: center;
margin: 0 15%;
}

View File

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

View File

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

View File

@ -1,13 +1,21 @@
.list-collection {
@extend %list-collection;
}
%list-collection {
.list-collection-scroll-virtual {
@extend %list-collection-scroll-virtual;
}
%list-collection-scroll-virtual {
height: 500px;
position: relative;
}
%list-collection > ul {
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 {
bottom: auto;

View File

@ -1,20 +1,13 @@
import { get, set, computed } from '@ember/object';
import Changeset from 'ember-changeset';
import { get, set } from '@ember/object';
import { Changeset as createChangeset } from 'ember-changeset';
import Changeset from 'consul-ui/utils/form/changeset';
import lookupValidator from 'ember-changeset-validations';
// Keep these here for now so forms are easy to make
// TODO: Probably move this to utils/form/parse-element-name
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) {
return new Changeset(data, lookupValidator(validators), validators);
return createChangeset(data, lookupValidator(validators), validators, { changeset: Changeset });
};
/**
* 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 validateSometimes from 'ember-changeset-conditional-validations/validators/sometimes';
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 })],
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",
"dart-sass": "^1.25.0",
"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-app-version": "^3.2.0",
"ember-cli-autoprefixer": "^0.8.1",

View File

@ -33,7 +33,7 @@ Feature: dc / intentions / create: Intention Create
# Specifically set deny
And I click "[value=deny]"
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:
SourceName: web

View File

@ -2,13 +2,16 @@
Feature: dc / intentions / deleting: Deleting items with confirmations, success and error notifications
Background:
Given 1 datacenter model with the value "datacenter"
Scenario: Deleting a intention model from the intention listing page
Given 1 intention model from yaml
And 1 intention model from yaml
---
SourceNS: default
SourceName: name
DestinationNS: default
DestinationName: destination
ID: ee52203d-989f-4f7a-ab5a-2bef004164ca
Meta: ~
---
Scenario: Deleting a intention model from the intention listing page
When I visit the intentions page for yaml
---
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 delete 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 "success" class
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 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 "success" class
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
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 confirmDelete
And "[data-notification]" has the "notification-update" class
@ -47,7 +50,7 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
dc: datacenter
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
body: "duplicate intention found:"

View File

@ -4,6 +4,10 @@ Feature: dc / intentions / update: Intention Update
Given 1 datacenter model with the value "datacenter"
And 1 intention model from yaml
---
SourceNS: default
SourceName: web
DestinationNS: default
DestinationName: db
ID: intention-id
---
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 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]
Action: [Action]
@ -35,7 +39,7 @@ Feature: dc / intentions / update: Intention Update
| Desc | allow |
------------------------------
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
Then the url should be /datacenter/intentions/intention-id
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
Action: allow
Meta: ~
SourceNS: default
SourceName: name
DestinationNS: default
DestinationName: destination
- ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f1
Action: deny
Meta: ~
@ -37,6 +42,6 @@ Feature: dc / services / show / intentions: Intentions per service
And I click actions on the intentions
And I click delete 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 "success" class

View File

@ -3,7 +3,6 @@ import { setupTest } from 'ember-qunit';
module('Integration | Adapter | intention', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const legacyId = 'intention-name';
const id = 'SourceNS:SourceName:DestinationNS:DestinationName';
test('requestForQuery returns the correct url', function(assert) {
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) {
const adapter = this.owner.lookup('adapter:intention');
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
.requestForCreateRecord(
client.url,
{},
{
Datacenter: dc,
ID: id,
SourceNS: 'SourceNS',
SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
}
)
.split('\n')[0];
@ -54,15 +56,17 @@ module('Integration | Adapter | intention', function(hooks) {
test('requestForUpdateRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention');
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
.requestForUpdateRecord(
client.url,
{},
{
Datacenter: dc,
ID: id,
LegacyID: legacyId,
SourceNS: 'SourceNS',
SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
}
)
.split('\n')[0];
@ -71,15 +75,17 @@ module('Integration | Adapter | intention', function(hooks) {
test('requestForDeleteRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention');
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
.requestForDeleteRecord(
client.url,
{},
{
Datacenter: dc,
ID: id,
LegacyID: legacyId,
SourceNS: 'SourceNS',
SourceName: 'SourceName',
DestinationNS: 'DestinationNS',
DestinationName: 'DestinationName',
}
)
.split('\n')[0];

View File

@ -1487,6 +1487,14 @@
"@glimmer/env" "^0.1.7"
"@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":
version "0.44.0"
resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.44.0.tgz#45df98d73812440206ae7bda87cfe04aaae21ed9"
@ -1520,14 +1528,14 @@
js-yaml "^3.13.1"
"@hashicorp/consul-api-double@^5.0.0":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.0.1.tgz#07880706ab26cc242332cef86b2c03b3b4ec4e56"
integrity sha512-uptXq/XTGL5uzGqvwRqC0tzHKCJMVAaRMucPxjbMb4r9wOmOdT4Z2BUJD8GDcCSFIWE8hbWeqAlCXRrokZ3wbw==
version "5.2.1"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.1.tgz#a411139fa1afa0dfaf1b9973f21275530a39939b"
integrity sha512-ASQv2I8iprnFmpAvbHEoKE8MXTpOxdeBan6nkgobmz4OyvMcqu/h29CGEXZ9j63NX6+nxmE84nV5yAqADRubGQ==
"@hashicorp/ember-cli-api-double@^3.1.0":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-3.1.1.tgz#ba16a514131ce409054d1ae1a71483941d937d37"
integrity sha512-VLvV/m+Sx+vG+tHK1FeVjiBXwt8KcIWqgFavglrEBTkVTA2o7uP0xN9nKOJjos49KK+h1K3fCwMK5ltz7Kt97w==
version "3.1.2"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-3.1.2.tgz#0eaee116da1431ed63e55eea9ff9c28028cf9f8c"
integrity sha512-4j4JxIHBeo5KjTfcEAIrzjtiWBTxPzTTBMiigNgKAIWAtO6Hz58LQ6kDJl8MN52kSq2uSBlFJpp6aQhfwJaPtw==
dependencies:
"@hashicorp/api-double" "^1.6.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"
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:
version "4.1.0"
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==
caniuse-lite@^1.0.30000844:
version "1.0.30001125"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001125.tgz#2a1a51ee045a0a2207474b086f628c34725e997b"
integrity sha512-9f+r7BW8Qli917mU3j0fUaTweT3f3vnX/Lcs+1C73V+RADmFme+Ih0Br8vONQi3X0lseOe6ZHfsZLCA8MSjxUA==
version "1.0.30001137"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001137.tgz#6f0127b1d3788742561a25af3607a17fc778b803"
integrity sha512-54xKQZTqZrKVHmVz0+UvdZR6kQc7pJDgfhsMYDG19ID1BWoNnDMFm5Q3uSBSU401pBvKYMsHAt9qhEDcxmk8aw==
capture-exit@^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==
electron-to-chromium@^1.3.47:
version "1.3.565"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.565.tgz#8511797ab2b66b767e1aef4eb17d636bf01a2c72"
integrity sha512-me5dGlHFd8Q7mKhqbWRLIYnKjw4i0fO6hmW0JBxa7tM87fBfNEjWokRnDF7V+Qme/9IYpwhfMn+soWs40tXWqg==
version "1.3.575"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.575.tgz#57065cfad7b977a817dba47b28e7eb4dbce3fc37"
integrity sha512-031VrjcilnE8bXivDGhEeuGjMZrjTAeyAKm3XWPY9SvGYE6Hn8003gCqoNszFu6lh1v0gDx5hrM0VE1cPSMUkQ==
elliptic@^6.0.0, elliptic@^6.5.2:
version "6.5.3"
@ -5286,26 +5299,33 @@ ember-basic-dropdown@^3.0.3:
ember-maybe-in-element "^0.4.0"
ember-truth-helpers "2.1.0"
ember-changeset-validations@^3.0.2:
version "3.7.0"
resolved "https://registry.yarnpkg.com/ember-changeset-validations/-/ember-changeset-validations-3.7.0.tgz#74875705128f56ffe22bf54fe6856838dc9caa7d"
integrity sha512-E4Um4IV5UO72FdT1GWcI8EbNOHE7eS16XFokk6UaKpXs3MLuI1Ev6Zb8Q9Z20g/9/IieBI4XddnGd+1FpXz6aA==
ember-changeset-conditional-validations@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/ember-changeset-conditional-validations/-/ember-changeset-conditional-validations-0.6.0.tgz#78369ad3af0aea338e00a9bdf1b622fb512d9a00"
integrity sha512-U9TZFhhLC+5XRqcI5sNfJwGVcVZvXJxwrRQrrTYLImHe/+tcgP/TagE0f0DBrgWV2u3lsztGHGwGUs86uc65rg==
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-htmlbars "^4.0.5"
ember-get-config "^0.2.4"
ember-validators "^2.0.0"
ember-changeset@^3.7.0:
version "3.7.1"
resolved "https://registry.yarnpkg.com/ember-changeset/-/ember-changeset-3.7.1.tgz#5826e2bb7151f85494208aedac25a00400bddf13"
integrity sha512-vYkF9LHoFwQJb9yytfbA9J888a82/4i+NQUeYtyuLtoD4Zty6Z1NzZ5eWwVU/RbsbK45x85T5FKmsxSS1/1cgw==
ember-changeset@^3.9.1:
version "3.9.1"
resolved "https://registry.yarnpkg.com/ember-changeset/-/ember-changeset-3.9.1.tgz#53b50be95364a71f38e68a01c33eba12808cd8a4"
integrity sha512-Ntf0fITb2klRZF+5s5xbBQ6HNuSn1IbwppyZPU8v7oh26QOlfjyxYE7QNWM3nZnybHkCrF1iSqN3s8xj3OoFCg==
dependencies:
"@glimmer/tracking" "^1.0.0"
"@glimmer/tracking" "^1.0.1"
ember-auto-import "^1.5.2"
ember-cli-babel "^7.19.0"
validated-changeset "~0.7.1"
validated-changeset "~0.9.1"
ember-cli-app-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"
integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA==
ember-cli-babel@7:
ember-cli-babel@7, ember-cli-babel@^7.8.0:
version "7.22.1"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.22.1.tgz#cad28b89cf0e184c93b863d09bc5ba4ce1d2e453"
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"
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"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.21.0.tgz#c79e888876aee87dfc3260aee7cb580b74264bbc"
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"
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"
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-4.3.1.tgz#4af8adc21ab3c4953f768956b7f7d207782cb175"
integrity sha512-CW6AY/yzjeVqoRtItOKj3hcYzc5dWPRETmeCzr2Iqjt5vxiVtpl0z5VTqHqIlT5fsFx6sGWBQXNHIe+ivYsxXQ==
@ -12957,10 +12997,10 @@ validate-npm-package-name@^3.0.0:
dependencies:
builtins "^1.0.3"
validated-changeset@~0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/validated-changeset/-/validated-changeset-0.7.1.tgz#cb2c11c93d5acfb2286e5bfca07f4a516f83c844"
integrity sha512-BbFK98Cp7WunEwLOW/oAi6qDZZFBOLkae0q5RZ3ne8ZkwB1sskOYfF5IqQhqubwxRb4emMhA2UgN5rdfOaZxXQ==
validated-changeset@~0.9.1:
version "0.9.1"
resolved "https://registry.yarnpkg.com/validated-changeset/-/validated-changeset-0.9.1.tgz#bb4773c7c5392dcc7ecfbc8ccc10c7fef2840d42"
integrity sha512-gEMvF+GN8ECLndHw5Ehc9ckXMgM+RRDK5+lCx2hGX9Qie1q68ixLEtbNXbPPhV4JHq6d7krVfTG+sEQ3X6zoHA==
vary@~1.1.2:
version "1.1.2"