diff --git a/addons/rose/addon/components/rose/table/index.hbs b/addons/rose/addon/components/rose/table/index.hbs
index 655e7dbd4..643a14cfa 100644
--- a/addons/rose/addon/components/rose/table/index.hbs
+++ b/addons/rose/addon/components/rose/table/index.hbs
@@ -1,4 +1,4 @@
-
+
{{#if @caption}}
{{@caption}}
{{/if}}
diff --git a/addons/rose/addon/components/rose/table/index.stories.mdx b/addons/rose/addon/components/rose/table/index.stories.mdx
index a720bd877..444419be4 100644
--- a/addons/rose/addon/components/rose/table/index.stories.mdx
+++ b/addons/rose/addon/components/rose/table/index.stories.mdx
@@ -1,27 +1,42 @@
import { Meta, Story, Preview } from '@storybook/addon-docs/blocks';
import { hbs } from 'ember-cli-htmlbars';
+import { boolean, select } from '@storybook/addon-knobs';
# Table
-A table component to display data in structure of rows and columns. It supports `header`, `body` and `footer` optional definitions. Each definition allows for a table `row` to be defined with either a `cell` or `headerCell` table element.
+A table component to display data in structure of rows and columns. It supports `header`, `body` and `footer` optional definitions. Each definition allows for a table `row` to be defined with either a `cell` or `headerCell` table element. Rows can be visually hidden, and cells can be shrinked to its content with, and center/right cell alignment is supported.
-```htmlbars
-
+Cell shrink should be applied to all the cells in a row. Otherwise the bigger cell with shrink applied will determinate the width of the row.
+
+```html
+
-
+
Address
Host Set
Name
- Description
+ Description
1.1.1.1
host-set-1, host-set-2
- optional name
+ optional name
+ description
+
+
+ 1.1.1.1
+ host-set-2
+ optional name
+ description
+
+
+ 1.1.1.1
+ host-set-1
+ optional name
description
@@ -36,22 +51,34 @@ A table component to display data in structure of rows and columns. It supports
{{
template: hbs`
-
+
-
- Address
- Host Set
- Name
- Description
+
+ Address
+ Host Set
+ Name
+ Description
- 1.1.1.1
+ 1.1.1.1
host-set-1, host-set-2
- optional name
+ optional name
description
+
+ 1.1.1.1
+ host-set-2
+ optional name
+ description
+
+
+ 1.1.1.1
+ host-set-1
+ optional name
+ description
+
@@ -60,6 +87,102 @@ A table component to display data in structure of rows and columns. It supports
`,
- context: {}
+ context: {
+ hideHeaderRow: boolean('hideHeaderRow', false),
+ cellShrink: boolean('cellShrink', false),
+ style: select(
+ 'style',
+ {
+ 'default': '',
+ 'condensed': 'condensed'
+ }
+ ),
+ cellAlignmentCenter: boolean('cellAlignmentCenter', false),
+ cellAlignmentRight: boolean('cellAlignmentRight', false),
+ },
+ }}
+
+
+
+ {{
+ template: hbs`
+
+
+
+ Key
+ Value
+
+
+
+
+ Key
+
+
+
+
+
+
+ Remove
+
+
+
+ Key
+
+
+
+ Choose an option
+ Value 1
+ Value 2
+ Value 3
+
+
+
+
+ Add
+
+
+
+ Key
+
+
+
+
+
+
+ Add
+
+
+
+ Key
+
+
+
+
+ Add
+
+
+
+
+
+ Footer
+
+
+
+ `,
+ context: {
+ style: select(
+ 'style',
+ {
+ 'condensed': 'condensed',
+ 'default': ''
+ }
+ )
+ }
}}
diff --git a/addons/rose/addon/components/rose/table/row/cell/index.hbs b/addons/rose/addon/components/rose/table/row/cell/index.hbs
new file mode 100644
index 000000000..fb5c4b157
--- /dev/null
+++ b/addons/rose/addon/components/rose/table/row/cell/index.hbs
@@ -0,0 +1 @@
+{{yield}}
\ No newline at end of file
diff --git a/addons/rose/addon/components/rose/table/row/cell/index.js b/addons/rose/addon/components/rose/table/row/cell/index.js
new file mode 100644
index 000000000..588c072cf
--- /dev/null
+++ b/addons/rose/addon/components/rose/table/row/cell/index.js
@@ -0,0 +1,28 @@
+import Component from '@ember/component';
+import layout from './index';
+import { computed } from '@ember/object';
+
+export default Component.extend({
+ layout,
+ tagName: 'td',
+ classNameBindings: ['cellTypeStyle', 'cellShrink', 'cellAlignmentStyle'],
+ cellTypeStyle: computed(function () {
+ return this.tagName.match('th')
+ ? `rose-table-header-cell`
+ : 'rose-table-cell';
+ }),
+ cellShrink: computed(function () {
+ if (this.shrink) {
+ return 'rose-table-cell-shrink'
+ }
+ }),
+ cellAlignmentStyle: computed(function () {
+ if (this.alignRight) {
+ return 'align-right';
+ }
+
+ if (this.alignCenter) {
+ return 'align-center';
+ }
+ })
+});
diff --git a/addons/rose/addon/components/rose/table/row/index.hbs b/addons/rose/addon/components/rose/table/row/index.hbs
index e5bad550e..65fb90e81 100644
--- a/addons/rose/addon/components/rose/table/row/index.hbs
+++ b/addons/rose/addon/components/rose/table/row/index.hbs
@@ -1,12 +1,6 @@
-
+
{{yield (hash
- cell=(component 'rose/anonymous'
- tagName='td'
- class='rose-table-cell'
- )
- headerCell=(component 'rose/anonymous'
- tagName='th'
- class='rose-table-header-cell'
- )
+ cell=(component 'rose/table/row/cell')
+ headerCell=(component 'rose/table/row/cell' tagName='th')
)}}
diff --git a/addons/rose/addon/styles/rose/components/table/_table.scss b/addons/rose/addon/styles/rose/components/table/_table.scss
index 0c581393e..f9b7b9daf 100644
--- a/addons/rose/addon/styles/rose/components/table/_table.scss
+++ b/addons/rose/addon/styles/rose/components/table/_table.scss
@@ -10,9 +10,20 @@
text-align: left;
}
+ // This styles are add to stop the input to apply extra margin (default on forms)
+ .rose-form-input-field,
+ .rose-form-select,
+ .rose-form-input {
+ margin: 0;
+ width: 100%;
+ }
+
.rose-table-cell,
.rose-table-header-cell {
vertical-align: middle;
+
+ &.align-right { text-align: right; }
+ &.align-center { text-align: center; }
}
.rose-table-header-cell {
@@ -35,7 +46,28 @@
}
}
+ .rose-table-cell-shrink {
+ width: 1%;
+ white-space: nowrap;
+ }
+
[class*='rose-button'] {
margin-bottom: 0;
}
+
+ &.rose-table-condensed {
+ .rose-table-cell {
+ @include type.type(s);
+ $lineHeight: 36 / 14;
+ line-height: $lineHeight;
+ padding: (sizing.rems(s) / 2) sizing.rems(s);
+ }
+
+ }
+
+ .rose-table-row-visually-hidden {
+ position: absolute;
+ top: -9999rem;
+ left: -9999rem;
+ }
}
diff --git a/addons/rose/app/components/rose/table/row/cell.js b/addons/rose/app/components/rose/table/row/cell.js
new file mode 100644
index 000000000..50e7502b4
--- /dev/null
+++ b/addons/rose/app/components/rose/table/row/cell.js
@@ -0,0 +1 @@
+export { default } from 'rose/components/rose/table/row/cell';
diff --git a/addons/rose/tests/dummy/app/templates/index.hbs b/addons/rose/tests/dummy/app/templates/index.hbs
index 8bb8b116d..e82d02286 100644
--- a/addons/rose/tests/dummy/app/templates/index.hbs
+++ b/addons/rose/tests/dummy/app/templates/index.hbs
@@ -1,93 +1,196 @@
-
-
+Condensed Table
+
+
+
+ Address
+ Host Set
+ Name
+ Description
+
+
+
+
+ 1.1.1.1
+ host-set-1, host-set-2
+ optional name
+ description
+
+
+
+
+ Footer
+
+
+
-
-
- Menu item
- Menu item - danger
-
- Button menu item
-
+Condensed Table with Shrink Cells
+
+
+
+ Address
+ Host Set
+ Name
+ Description
+
+
+
+
+ 1.1.1.1
+ host-set-1, host-set-2
+
+ Remove
+
+ description
+
+
+
+
+ Footer
+
+
+
- Section
- Section
-
+Condensed Table with Right and Center aligned cells
+
+
+
+ Address
+ Host Set
+ Name
+ Description
+
+
+
+
+ 1.1.1.1
+ host-set-1, host-set-2
+ optional name
+ description
+
+
+
+
+ Footer
+
+
+
-
-
- Menu item
- Menu item - danger
- Button - default
-
- Button - danger
-
-
-
+Condensed Table Key/Value Lookalike (edit mode)
+
+
+
+ Key
+ Value
+
+
+
+
+ Key
+
+
+
+
+
+
+ Remove
+
+
+
+ Key
+
+
+
+ Choose an option
+ Value 1
+ Value 2
+ Value 3
+
+
+
+
+ Add
+
+
+
+ Key
+
+
+
+
+
+
+ Add
+
+
+
+ Key
+
+
+
+
+ Add
+
+
+
+
+
+ Footer
+
+
+
-
- Menu item
- Menu item - danger
-
+Condensed Table With Visually Hidden Header & Footer
+
+
+
+ Address
+ Host Set
+ Name
+ Description
+
+
+
+
+ 1.1.1.1
+ host-set-1, host-set-2
+ optional name
+ description
+
+
+
+
+ Footer
+
+
+
- Button menu item
- Button - danger
-
-
-
- Menu item
-
-
-
-
- Menu item
- Button menu item
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- <:header>
- Authentication Pending
-
- <:body>
- Complete authentication in the newly-opened window.
- Don't see the new window? Try again.
- Cancel
-
-
+Table
+
+
+
+ Address
+ Host Set
+ Name
+ Description
+
+
+
+
+ 1.1.1.1
+ host-set-1, host-set-2
+ optional name
+ description
+
+
+
+
+ Footer
+
+
+
\ No newline at end of file
diff --git a/addons/rose/tests/integration/components/rose/table-test.js b/addons/rose/tests/integration/components/rose/table-test.js
index 06f2a5b8c..aa78b58eb 100644
--- a/addons/rose/tests/integration/components/rose/table-test.js
+++ b/addons/rose/tests/integration/components/rose/table-test.js
@@ -43,4 +43,9 @@ module('Integration | Component | rose/table', function (hooks) {
`);
assert.ok(find('tfoot'));
});
+
+ test('it supports a style class', async function (assert) {
+ await render(hbs``);
+ assert.ok(find('.rose-table-condensed'));
+ });
});
diff --git a/addons/rose/tests/integration/components/rose/table/row-test.js b/addons/rose/tests/integration/components/rose/table/row-test.js
index 94c57cd08..2a0c1f758 100644
--- a/addons/rose/tests/integration/components/rose/table/row-test.js
+++ b/addons/rose/tests/integration/components/rose/table/row-test.js
@@ -7,17 +7,20 @@ module('Integration | Component | rose/table/row', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
+ assert.expect(2);
await render(hbs``);
assert.ok(find('tr'));
assert.ok(find('.rose-table-row'));
});
test('it renders with attributes', async function (assert) {
+ assert.expect(1);
await render(hbs``);
assert.ok(find('#row'));
});
test('it renders a cell', async function (assert) {
+ assert.expect(2);
await render(hbs`
`);
@@ -26,10 +29,17 @@ module('Integration | Component | rose/table/row', function (hooks) {
});
test('it renders a header cell', async function (assert) {
+ assert.expect(2);
await render(hbs`
`);
assert.ok(find('th'));
assert.ok(find('.rose-table-header-cell'));
});
+
+ test('it can be hidden', async function (assert) {
+ assert.expect(1);
+ await render(hbs``);
+ assert.ok(find('.rose-table-row-visually-hidden'));
+ });
});
diff --git a/addons/rose/tests/integration/components/rose/table/row/cell-test.js b/addons/rose/tests/integration/components/rose/table/row/cell-test.js
new file mode 100644
index 000000000..4b8c3332d
--- /dev/null
+++ b/addons/rose/tests/integration/components/rose/table/row/cell-test.js
@@ -0,0 +1,76 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, find } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | rose/table/row/cell', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function (assert) {
+ assert.expect(2);
+ await render(hbs`
+ content
+ `);
+ assert.ok(find('td'));
+ assert.equal(find('.rose-table-cell').textContent.trim(), 'content');
+ });
+
+ test('it renders with attributes', async function (assert) {
+ assert.expect(1);
+ await render(hbs``);
+ assert.ok(find('#cell'));
+ });
+
+ test('it renders as header cell', async function (assert) {
+ assert.expect(4);
+ await render(hbs``);
+ assert.ok(find('th'));
+ assert.ok(find('.rose-table-header-cell'));
+ assert.notOk(find('td'));
+ assert.notOk(find('.rose-table-cell'));
+ });
+
+ test('it adds shrink style class', async function (assert) {
+ assert.expect(1);
+ await render(hbs``);
+ assert.ok(find('.rose-table-cell-shrink'));
+ });
+
+ test('it does not add expand style class in default cells', async function (assert) {
+ assert.expect(1);
+ await render(hbs``);
+ assert.notOk(find('.rose-table-cell-shrink'));
+ });
+
+ test('it renders with align center in a header cell', async function (assert) {
+ assert.expect(2);
+ await render(
+ hbs``
+ );
+ assert.ok(find('.rose-table-header-cell'));
+ assert.ok(find('.align-center'));
+ });
+
+ test('it renders with align center in a body', async function (assert) {
+ assert.expect(2);
+ await render(hbs``);
+ assert.ok(find('.rose-table-cell'));
+ assert.ok(find('.align-center'));
+ });
+
+ test('it renders with align right in a header cell', async function (assert) {
+ assert.expect(2);
+ await render(
+ hbs``
+ );
+ assert.ok(find('.rose-table-header-cell'));
+ assert.ok(find('.align-right'));
+ });
+
+ test('it renders with align right', async function (assert) {
+ assert.expect(2);
+ await render(hbs``);
+ assert.ok(find('.rose-table-cell'));
+ assert.ok(find('.align-right'));
+ });
+});
diff --git a/ui/desktop/CHANGELOG.md b/ui/desktop/CHANGELOG.md
index e72d90c5e..42bd5a07d 100644
--- a/ui/desktop/CHANGELOG.md
+++ b/ui/desktop/CHANGELOG.md
@@ -2,6 +2,16 @@
Canonical reference for changes, improvements, and bugfixes for Boundary Desktop.
+## v1.0.1 [CLI 0.2.2] (2021.05.19)
+
+### New
+
+- Add prompt to close sessions when quitting client. ([PR](https://github.com/hashicorp/boundary-ui/pull/555))
+
+### Bug Fixes
+
+- Close spawned boundary process on session cancel. ([PR](https://github.com/hashicorp/boundary-ui/pull/549))
+
## v1.0.0 [CLI 0.2.0] (2021.04.14)
### New
diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
index 272cf0672..cd11b0569 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/sessions.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
@@ -11,6 +11,7 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
// =services
+ @service ipc;
@service session;
// =attributes
@@ -83,5 +84,6 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
@notifySuccess('notifications.canceled-success')
async cancelSession(session) {
await session.cancelSession();
+ await this.ipc.invoke('stop', { session_id: session.id });
}
}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js
index 4f0281e2f..f84af4eac 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target/sessions.js
@@ -1,8 +1,13 @@
import Route from '@ember/routing/route';
import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeProjectsTargetsTargetSessionsRoute extends Route {
+ // =services
+
+ @service ipc;
+
// =actions
/**
@@ -14,5 +19,6 @@ export default class ScopesScopeProjectsTargetsTargetSessionsRoute extends Route
@notifySuccess('notifications.canceled-success')
async cancelSession(session) {
await session.cancelSession();
+ await this.ipc.invoke('stop', { session_id: session.id });
}
}
diff --git a/ui/desktop/electron-app/.gitignore b/ui/desktop/electron-app/.gitignore
index 0928c4d31..75a95bf75 100644
--- a/ui/desktop/electron-app/.gitignore
+++ b/ui/desktop/electron-app/.gitignore
@@ -93,7 +93,7 @@ ember-dist/
ember-test/
# CLI
-cli/
+/cli/
# Autoupdate
nextVersion/
diff --git a/ui/desktop/electron-app/config/cli.js b/ui/desktop/electron-app/config/cli.js
index 9ebc26ea7..6c7ecb6a4 100644
--- a/ui/desktop/electron-app/config/cli.js
+++ b/ui/desktop/electron-app/config/cli.js
@@ -4,7 +4,7 @@ const https = require('https');
const decompress = require('decompress');
const os = require('os');
-const artifactVersion = '0.2.0';
+const artifactVersion = '0.2.2';
const artifactDestination = path.resolve(__dirname, '..', 'cli');
const downloadArtifact = version => {
diff --git a/ui/desktop/electron-app/config/forge.config.js b/ui/desktop/electron-app/config/forge.config.js
index 293ae0952..c426f9aff 100644
--- a/ui/desktop/electron-app/config/forge.config.js
+++ b/ui/desktop/electron-app/config/forge.config.js
@@ -1,5 +1,5 @@
const process = require('process');
-const { version } = require('../src/boundary-cli.js');
+const { version } = require('../src/cli/index.js');
const formattedCLIVersion = version().formatted;
diff --git a/ui/desktop/electron-app/src/boundary-cli.js b/ui/desktop/electron-app/src/boundary-cli.js
deleted file mode 100644
index 453a959df..000000000
--- a/ui/desktop/electron-app/src/boundary-cli.js
+++ /dev/null
@@ -1,58 +0,0 @@
-const path = require('path');
-const { spawnAsyncJSONPromise, spawnSync } = require('./spawn-promise');
-const origin = require('./origin.js');
-
-const cliPath = async () => path.resolve(__dirname, '..', 'cli', 'boundary');
-
-/**
- * Super paranoid shell quote/escape and validation. Input must be base62.
- * @param {string} str
- */
-const escapeAndValidateBase62 = (str) => {
- const candidate = str.toString();
- if (candidate.match(/^[A-Za-z0-9_]*$/)) return candidate;
- throw new Error(`
- Could not invoke command:
- input contained unsafe characters.
- `);
-};
-
-module.exports = {
- // Check boundary cli existence
- exists: () => Boolean(cliPath()),
-
- // Initiate connection and return output
- connect: (target_id, token, host_id) => {
- const addr = origin.origin;
- const sanitized = {
- target_id: escapeAndValidateBase62(target_id),
- token: escapeAndValidateBase62(token),
- };
- if (host_id) sanitized.host_id = escapeAndValidateBase62(host_id);
- const command = [
- 'connect',
- `-target-id=${sanitized.target_id}`,
- `-token=${sanitized.token}`,
- `-addr=${addr}`,
- '-format=json',
- ];
-
- if (host_id) command.push(`-host-id=${sanitized.host_id}`);
- return spawnAsyncJSONPromise(command);
- },
-
- // Returns JSON-formatted version information from the CLI
- version: () => {
- const command = ['-v'];
- const rawOutput = spawnSync(command);
- let gitRevision = /Git Revision:\s*(?.*)\n/.exec(rawOutput);
- let versionNumber = /Version Number:\s*(?.*)\n/.exec(rawOutput);
- if (gitRevision) gitRevision = gitRevision.groups.rev;
- if (versionNumber) versionNumber = versionNumber.groups.ver;
- const formatted = versionNumber
- ? `CLI Version: ${versionNumber}\nCLI Commit: ${gitRevision}`
- : `CLI Commit: ${gitRevision}`;
- return { gitRevision, versionNumber, formatted };
- return rawOutput;
- },
-};
diff --git a/ui/desktop/electron-app/src/cli/index.js b/ui/desktop/electron-app/src/cli/index.js
new file mode 100644
index 000000000..0aaad4e31
--- /dev/null
+++ b/ui/desktop/electron-app/src/cli/index.js
@@ -0,0 +1,23 @@
+const path = require('path');
+const { spawnSync } = require('../helpers/spawn-promise.js');
+
+const cliPath = async () => path.resolve(__dirname, '..', '..', 'cli', 'boundary');
+
+module.exports = {
+ // Check boundary cli existence
+ exists: () => Boolean(cliPath()),
+
+ // Returns JSON-formatted version information from the CLI
+ version: () => {
+ const command = ['-v'];
+ const rawOutput = spawnSync(command);
+ let gitRevision = /Git Revision:\s*(?.*)\n/.exec(rawOutput);
+ let versionNumber = /Version Number:\s*(?.*)\n/.exec(rawOutput);
+ if (gitRevision) gitRevision = gitRevision.groups.rev;
+ if (versionNumber) versionNumber = versionNumber.groups.ver;
+ const formatted = versionNumber
+ ? `CLI Version: ${versionNumber}\nCLI Commit: ${gitRevision}`
+ : `CLI Commit: ${gitRevision}`;
+ return { gitRevision, versionNumber, formatted };
+ },
+};
diff --git a/ui/desktop/electron-app/src/content-security-policy.js b/ui/desktop/electron-app/src/config/content-security-policy.js
similarity index 88%
rename from ui/desktop/electron-app/src/content-security-policy.js
rename to ui/desktop/electron-app/src/config/content-security-policy.js
index 6f5156bb6..19ccafc93 100644
--- a/ui/desktop/electron-app/src/content-security-policy.js
+++ b/ui/desktop/electron-app/src/config/content-security-policy.js
@@ -1,4 +1,4 @@
-const origin = require('./origin.js');
+const runtimeSettings = require('../services/runtime-settings.js');
const isDev = require('electron-is-dev');
const csp = {
@@ -28,9 +28,9 @@ const generateCSPHeader = () => {
const policy = Object.assign({}, csp);
// If an origin is specified, add it to the connect-src directive
- if (origin.origin) {
+ if (runtimeSettings.origin) {
policy['connect-src'] = policy['connect-src'].slice();
- policy['connect-src'].push(origin.origin);
+ policy['connect-src'].push(runtimeSettings.origin);
}
return Object.keys(policy)
diff --git a/ui/desktop/electron-app/src/menu.js b/ui/desktop/electron-app/src/config/menu.js
similarity index 97%
rename from ui/desktop/electron-app/src/menu.js
rename to ui/desktop/electron-app/src/config/menu.js
index 2ba8eb6e7..5f669e4bb 100644
--- a/ui/desktop/electron-app/src/menu.js
+++ b/ui/desktop/electron-app/src/config/menu.js
@@ -1,5 +1,5 @@
const { shell } = require('electron');
-const appUpdater = require('./app-updater.js');
+const appUpdater = require('../helpers/app-updater.js');
const generateMenuTemplate = () => {
return [
diff --git a/ui/desktop/electron-app/src/app-updater.js b/ui/desktop/electron-app/src/helpers/app-updater.js
similarity index 100%
rename from ui/desktop/electron-app/src/app-updater.js
rename to ui/desktop/electron-app/src/helpers/app-updater.js
diff --git a/ui/desktop/electron-app/src/helpers/net-request-promise.js b/ui/desktop/electron-app/src/helpers/net-request-promise.js
new file mode 100644
index 000000000..c86030c79
--- /dev/null
+++ b/ui/desktop/electron-app/src/helpers/net-request-promise.js
@@ -0,0 +1,26 @@
+const { net } = require('electron');
+
+const requestTimeoutSeconds = 10;
+// Simple promise wrapper around Electron's net.request feature.
+// https://www.electronjs.org/docs/api/client-request
+const netRequestPromise = (url) =>
+ new Promise((resolve, reject) => {
+ try {
+ const request = net.request(url);
+ // We don't worry about canceling the request timeout timer once a
+ // request ends because: request.abort() has no effect after a request
+ // ends and the promise will already have been completed, so a rejection
+ // has no effect either.
+ const requestTimeout = setTimeout(() => {
+ request.abort();
+ reject(new Error('Request timeout'));
+ }, requestTimeoutSeconds * 1000);
+ request.on('error', reject);
+ request.on('response', resolve);
+ request.end();
+ } catch (e) {
+ reject(e);
+ }
+ });
+
+module.exports = netRequestPromise;
diff --git a/ui/desktop/electron-app/src/spawn-promise.js b/ui/desktop/electron-app/src/helpers/spawn-promise.js
similarity index 70%
rename from ui/desktop/electron-app/src/spawn-promise.js
rename to ui/desktop/electron-app/src/helpers/spawn-promise.js
index dc8069791..b8ff5d980 100644
--- a/ui/desktop/electron-app/src/spawn-promise.js
+++ b/ui/desktop/electron-app/src/helpers/spawn-promise.js
@@ -1,17 +1,8 @@
const path = require('path');
const { spawn, spawnSync } = require('child_process');
+const jsonify = require('../utils/jsonify.js');
-const boundaryPath = path.resolve(__dirname, '..', 'cli', 'boundary');
-
-// Convert to json
-const jsonify = (data) => {
- if (typeof data !== 'string') data = JSON.stringify(data);
- try {
- return JSON.parse(data);
- } catch (e) {
- // Ignore parse errors
- }
-};
+const boundaryPath = path.resolve(__dirname, '..', '..', 'cli', 'boundary');
// You can throw exceptions, or allow them to occur, and this is supported.
// Exceptions thrown in this way will be returned to the UI as an
@@ -42,25 +33,37 @@ module.exports = {
let outputStream = '';
let errorStream = '';
- childProcess.stdout.on('data', (data) => {
+ const processData = (data) => {
outputStream += data.toString();
const jsonData = jsonify(outputStream);
- if (jsonData) resolve(jsonData);
- });
+ if (jsonData) {
+ childProcess.removeAllListeners();
+ resolve({ childProcess, response: jsonData });
+ }
+ };
- childProcess.stderr.on('data', (data) => {
- errorStream += data.toString();
+ childProcess.stdout.on('data', processData);
+
+ // Errors are cli returned json objects
+ const processError = (error) => {
+ errorStream += error.toString();
const jsonData = jsonify(errorStream);
if (jsonData) {
const error = jsonData.api_error || jsonData.error;
+ childProcess.removeAllListeners();
reject(new Error(error.message));
}
- });
+ };
+
+ childProcess.stderr.on('error', processError);
});
},
/**
- *
+ * Spawn child process and return output immediately.
+ * This function is intended for non-connection related tasks.
+ * @param {string} command
+ * @return {string}
*/
spawnSync(command) {
const childProcess = spawnSync(boundaryPath, command);
diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js
index 0fbcb3524..1f220eb85 100644
--- a/ui/desktop/electron-app/src/index.js
+++ b/ui/desktop/electron-app/src/index.js
@@ -7,19 +7,20 @@ const {
const {
session,
app,
+ dialog,
protocol,
BrowserWindow,
- ipcMain,
Menu,
MenuItem,
} = require('electron');
-require('./handlers.js');
+require('./ipc/handlers.js');
-const origin = require('./origin.js');
-const { generateCSPHeader } = require('./content-security-policy.js');
+const runtimeSettings = require('./services/runtime-settings.js');
+const { generateCSPHeader } = require('./config/content-security-policy.js');
+const sessionManager = require('./services/session-manager.js');
-const menu = require('./menu.js');
-const appUpdater = require('./app-updater.js');
+const menu = require('./config/menu.js');
+const appUpdater = require('./helpers/app-updater.js');
const isDev = require('electron-is-dev');
// Register the custom file protocol
@@ -131,7 +132,7 @@ app.on('ready', async () => {
// If the user-specified origin changes, reload the page so that
// the CSP can be refreshed with the this source allowed
- origin.onOriginChange(() => mainWindow.loadURL(emberAppURL));
+ runtimeSettings.onOriginChange(() => mainWindow.loadURL(emberAppURL));
// If you want to open up dev tools programmatically, call
// mainWindow.openDevTools();
@@ -183,6 +184,30 @@ app.on('ready', async () => {
});
});
+/**
+ * Prompt for closing spawned processes
+ */
+app.on('before-quit', (event) => {
+ if (sessionManager.hasRunningSessions) {
+ const dialogOpts = {
+ type: 'question',
+ buttons: ['Close', 'Cancel'],
+ detail: 'Close sessions before quitting?',
+ };
+
+ dialog.showMessageBox(dialogOpts).then((returnValue) => {
+ switch (returnValue.response) {
+ case 0:
+ sessionManager.stopAll();
+ break;
+ default:
+ event.preventDefault();
+ break;
+ }
+ });
+ }
+});
+
// Handle an unhandled error in the main thread
//
// Note that 'uncaughtException' is a crude mechanism for exception handling intended to
diff --git a/ui/desktop/electron-app/src/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js
similarity index 66%
rename from ui/desktop/electron-app/src/handlers.js
rename to ui/desktop/electron-app/src/ipc/handlers.js
index 3f4eb94c7..529fc34a5 100644
--- a/ui/desktop/electron-app/src/handlers.js
+++ b/ui/desktop/electron-app/src/ipc/handlers.js
@@ -1,30 +1,31 @@
const { shell } = require('electron');
const isDev = require('electron-is-dev');
-const handle = require('./ipc-handler');
-const boundaryCli = require('./boundary-cli');
-const origin = require('./origin.js');
+const handle = require('./ipc-handler.js');
+const boundaryCli = require('../cli/index.js');
+const sessionManager = require('../services/session-manager.js');
+const runtimeSettings = require('../services/runtime-settings.js');
+const sanitizer = require('../utils/sanitizer.js');
/**
* Returns the current runtime origin, which is used by the main thread to
* rewrite the CSP to allow requests.
*/
-handle('getOrigin', () => origin.origin);
+handle('getOrigin', () => runtimeSettings.origin);
/**
* Sets the origin to be used in the content security policy and triggers
* a main window reload.
*/
handle('setOrigin', async (requestOrigin) => {
- await origin.validateOrigin(requestOrigin);
- origin.origin = requestOrigin;
+ const origin = sanitizer.urlValidate(requestOrigin);
+ await runtimeSettings.validateOrigin(origin);
+ runtimeSettings.origin = origin;
});
/**
* Resets the origin.
*/
-handle('resetOrigin', async (requestOrigin) => {
- origin.resetOrigin();
-});
+handle('resetOrigin', async () => runtimeSettings.resetOrigin());
/**
* Opens the specified URL in an external browser. Only secure HTTPs URLs are
@@ -56,5 +57,10 @@ handle('cliExists', () => boundaryCli.exists());
* Establishes a boundary session and returns session details.
*/
handle('connect', ({ target_id, token, host_id }) =>
- boundaryCli.connect(target_id, token, host_id)
+ sessionManager.start(runtimeSettings.origin, target_id, token, host_id)
);
+
+/**
+ * Cancel an established boundary session spawned process.
+ */
+handle('stop', ({ session_id }) => sessionManager.stopById(session_id));
diff --git a/ui/desktop/electron-app/src/ipc-handler.js b/ui/desktop/electron-app/src/ipc/ipc-handler.js
similarity index 100%
rename from ui/desktop/electron-app/src/ipc-handler.js
rename to ui/desktop/electron-app/src/ipc/ipc-handler.js
diff --git a/ui/desktop/electron-app/src/origin.js b/ui/desktop/electron-app/src/models/runtime-settings.js
similarity index 59%
rename from ui/desktop/electron-app/src/origin.js
rename to ui/desktop/electron-app/src/models/runtime-settings.js
index 5cc150c32..fdd91608c 100644
--- a/ui/desktop/electron-app/src/origin.js
+++ b/ui/desktop/electron-app/src/models/runtime-settings.js
@@ -1,34 +1,7 @@
-const { net } = require('electron');
-
-const requestTimeoutSeconds = 10;
-// Simple promise wrapper around Electron's net.request feature.
-// https://www.electronjs.org/docs/api/client-request
-const netRequestPromise = (url) =>
- new Promise((resolve, reject) => {
- try {
- const request = net.request(url);
- // We don't worry about canceling the request timeout timer once a
- // request ends because: request.abort() has no effect after a request
- // ends and the promise will already have been completed, so a rejection
- // has no effect either.
- const requestTimeout = setTimeout(() => {
- request.abort();
- reject(new Error('Request timeout'));
- }, requestTimeoutSeconds * 1000);
- request.on('error', reject);
- request.on('response', resolve);
- request.end();
- } catch (e) {
- reject(e);
- }
- });
-
-// Provides a singleton class instance to enable a consistent view of
-// runtime settings across the application.
+const netRequestPromise = require('../helpers/net-request-promise.js');
// Runtime settings are any configuration, especially user-specified,
// set up at runtime. For example, the Boundary origin is a runtime setting.
-
class RuntimeSettings {
// Internal private origin is exposed via getter/setter below.
#origin = undefined;
@@ -84,6 +57,4 @@ class RuntimeSettings {
}
}
-const runtimeSettings = new RuntimeSettings();
-
-module.exports = runtimeSettings;
+module.exports = RuntimeSettings;
diff --git a/ui/desktop/electron-app/src/models/session-manager.js b/ui/desktop/electron-app/src/models/session-manager.js
new file mode 100644
index 000000000..311d86899
--- /dev/null
+++ b/ui/desktop/electron-app/src/models/session-manager.js
@@ -0,0 +1,42 @@
+const Session = require('./session.js');
+
+class SessionManager {
+ #sessions = [];
+
+ /**
+ * Checks for running sessions
+ * @returns {boolean}
+ */
+ get hasRunningSessions() {
+ return Boolean(this.#sessions.find((session) => session.isRunning));
+ }
+
+ /**
+ * Start a session and track it.
+ * Returns session proxy details if successfully started.
+ * @param {string} addr
+ * @param {string} target_id
+ * @param {string} token
+ * @param {string} host_id
+ */
+ start(addr, target_id, token, host_id) {
+ const session = new Session(addr, target_id, token, host_id);
+ this.#sessions.push(session);
+ return session.start();
+ }
+
+ /**
+ * Stop a session using identifier.
+ * @param {string} session_id
+ */
+ stopById(session_id) {
+ const session = this.#sessions.find((session) => session.id === session_id);
+ return session?.stop?.();
+ }
+
+ stopAll() {
+ this.#sessions.forEach((session) => session.stop());
+ }
+}
+
+module.exports = SessionManager;
diff --git a/ui/desktop/electron-app/src/models/session.js b/ui/desktop/electron-app/src/models/session.js
new file mode 100644
index 000000000..a59316f7f
--- /dev/null
+++ b/ui/desktop/electron-app/src/models/session.js
@@ -0,0 +1,101 @@
+const { spawnAsyncJSONPromise } = require('../helpers/spawn-promise.js');
+const sanitizer = require('../utils/sanitizer.js');
+
+class Session {
+ #id;
+ #addr;
+ #token;
+ #hostId;
+ #process;
+ #targetId;
+ #proxyDetails;
+
+ /**
+ * Initialize a session to a controller address
+ * using target details.
+ * @param {string} addr
+ * @param {string} targetId
+ * @param {string} token
+ * @param {string} hostId
+ */
+ constructor(addr, targetId, token, hostId) {
+ this.#addr = addr;
+ this.#targetId = targetId;
+ this.#token = token;
+ this.#hostId = hostId;
+ }
+
+ /**
+ * Session id
+ * @return {string}
+ */
+ get id() {
+ return this.#id;
+ }
+
+ /**
+ * @return {boolean}
+ */
+ get isRunning() {
+ return !this.#process.killed;
+ }
+
+ /**
+ * Using cli, initialize a session to a target.
+ * Tracks local proxy details if successful.
+ */
+ start() {
+ const command = this.cliCommand();
+ return spawnAsyncJSONPromise(command).then((spawnedSession) => {
+ this.#process = spawnedSession.childProcess;
+ this.#proxyDetails = spawnedSession.response;
+ this.#process = spawnedSession.childProcess;
+ this.#id = this.#proxyDetails.session_id;
+ return this.#proxyDetails;
+ });
+ }
+
+ /**
+ * Stop proxy process used by session.
+ */
+ stop() {
+ return new Promise((resolve, reject) => {
+ if (this.isRunning) {
+ this.#process.on('close', () => resolve());
+ this.#process.on('error', (e) => reject(e));
+ this.#process.kill();
+ } else {
+ // Do nothing when process isn't running
+ resolve();
+ }
+ });
+ }
+
+ /**
+ * Generate cli command for session.
+ * @returns {[]}
+ */
+ cliCommand() {
+ const sanitized = {
+ target_id: sanitizer.base62EscapeAndValidate(this.#targetId),
+ token: sanitizer.base62EscapeAndValidate(this.#token),
+ addr: sanitizer.urlValidate(this.#addr),
+ };
+
+ const command = [
+ 'connect',
+ `-target-id=${sanitized.target_id}`,
+ `-token=${sanitized.token}`,
+ `-addr=${sanitized.addr}`,
+ '-format=json',
+ ];
+
+ if (this.#hostId) {
+ sanitized.host_id = sanitizer.base62EscapeAndValidate(this.#hostId);
+ command.push(`-host-id=${sanitized.host_id}`);
+ }
+ return command;
+ }
+}
+
+module.exports = Session;
diff --git a/ui/desktop/electron-app/src/services/runtime-settings.js b/ui/desktop/electron-app/src/services/runtime-settings.js
new file mode 100644
index 000000000..8c5634079
--- /dev/null
+++ b/ui/desktop/electron-app/src/services/runtime-settings.js
@@ -0,0 +1,8 @@
+const RuntimeSettings = require('../models/runtime-settings.js');
+
+// Provides a singleton class instance to enable a consistent view of
+// runtime settings across the application.
+
+const runtimeSettings = new RuntimeSettings();
+
+module.exports = runtimeSettings;
diff --git a/ui/desktop/electron-app/src/services/session-manager.js b/ui/desktop/electron-app/src/services/session-manager.js
new file mode 100644
index 000000000..dc96e096f
--- /dev/null
+++ b/ui/desktop/electron-app/src/services/session-manager.js
@@ -0,0 +1,7 @@
+const SessionManager = require('../models/session-manager.js');
+
+/**
+ * Singleton for SessionManager
+ */
+const sessionManager = new SessionManager();
+module.exports = sessionManager;
diff --git a/ui/desktop/electron-app/src/utils/jsonify.js b/ui/desktop/electron-app/src/utils/jsonify.js
new file mode 100644
index 000000000..e75bab6fb
--- /dev/null
+++ b/ui/desktop/electron-app/src/utils/jsonify.js
@@ -0,0 +1,11 @@
+// Convert to json
+const jsonify = (data) => {
+ if (typeof data !== 'string') data = JSON.stringify(data);
+ try {
+ return JSON.parse(data);
+ } catch (e) {
+ // Ignore parse errors
+ }
+};
+
+module.exports = jsonify;
diff --git a/ui/desktop/electron-app/src/utils/sanitizer.js b/ui/desktop/electron-app/src/utils/sanitizer.js
new file mode 100644
index 000000000..5dbc5efa9
--- /dev/null
+++ b/ui/desktop/electron-app/src/utils/sanitizer.js
@@ -0,0 +1,30 @@
+const { URL } = require('url');
+
+module.exports = {
+ /**
+ * Super paranoid shell quote/escape and validation. Input must be base62.
+ * @param {string} str
+ */
+ base62EscapeAndValidate: (str) => {
+ const candidate = str.toString();
+ if (candidate.match(/^[A-Za-z0-9_]*$/)) return candidate;
+ throw new Error(`
+ Could not invoke command:
+ input contained unsafe characters.
+ `);
+ },
+ /**
+ * Return a quoted string suitable for using in shell commands.
+ * @param {string} str
+ * @returns {string}
+ */
+ urlValidate: (str) => {
+ try {
+ const url = new URL(str);
+ return `${url.protocol}//${url.host}`;
+ } catch (e) {
+ // Parse error
+ throw new Error(`URL ${str} could not be validated.`);
+ }
+ },
+};
diff --git a/ui/desktop/tests/acceptance/projects/sessions-test.js b/ui/desktop/tests/acceptance/projects/sessions-test.js
index eb3e0bab1..2cdbafc16 100644
--- a/ui/desktop/tests/acceptance/projects/sessions-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions-test.js
@@ -19,6 +19,7 @@ import {
authenticateSession,
invalidateSession,
} from 'ember-simple-auth/test-support';
+import sinon from 'sinon';
module('Acceptance | projects | sessions', function (hooks) {
setupApplicationTest(hooks);
@@ -43,6 +44,7 @@ module('Acceptance | projects | sessions', function (hooks) {
const stubs = {
global: null,
org: null,
+ ipcService: null,
};
const urls = {
@@ -144,6 +146,9 @@ module('Acceptance | projects | sessions', function (hooks) {
window.addEventListener('message', messageHandler);
setDefaultOrigin(this);
+
+ const ipcService = this.owner.lookup('service:ipc');
+ stubs.ipcService = sinon.stub(ipcService, 'invoke');
});
hooks.afterEach(function () {
@@ -240,6 +245,7 @@ module('Acceptance | projects | sessions', function (hooks) {
test('cancelling a session', async function (assert) {
assert.expect(1);
+ stubs.ipcService.withArgs('stop');
later(async () => {
run.cancelTimers();
await click('tbody tr:first-child td:last-child button');
@@ -258,4 +264,15 @@ module('Acceptance | projects | sessions', function (hooks) {
}, 750);
await visit(urls.sessions);
});
+
+ test('cancelling a session with ipc error shows notification', async function (assert) {
+ assert.expect(1);
+ stubs.ipcService.withArgs('stop').throws();
+ later(async () => {
+ run.cancelTimers();
+ await click('tbody tr:first-child td:last-child button');
+ assert.ok(find('[role="alert"].is-error'));
+ }, 750);
+ await visit(urls.sessions);
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/targets/sessions-test.js b/ui/desktop/tests/acceptance/projects/targets/sessions-test.js
index a28682950..e6031ab88 100644
--- a/ui/desktop/tests/acceptance/projects/targets/sessions-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/sessions-test.js
@@ -248,6 +248,7 @@ module('Acceptance | projects | targets | sessions', function (hooks) {
test('cancelling a session', async function (assert) {
assert.expect(2);
const sessionsCount = this.server.schema.sessions.all().models.length;
+ stubs.ipcService.withArgs('cancel');
later(async () => {
run.cancelTimers();
await click('tbody tr:first-child td:last-child button');
@@ -268,6 +269,17 @@ module('Acceptance | projects | targets | sessions', function (hooks) {
await visit(urls.sessions);
});
+ test('cancelling a session with ipc error shows notification', async function (assert) {
+ assert.expect(1);
+ stubs.ipcService.withArgs('stop').throws();
+ later(async () => {
+ run.cancelTimers();
+ await click('tbody tr:first-child td:last-child button');
+ assert.ok(find('[role="alert"].is-error'));
+ }, 750);
+ await visit(urls.sessions);
+ });
+
test('connecting to a target', async function (assert) {
assert.expect(3);
stubs.ipcService.withArgs('cliExists').returns(true);
diff --git a/yarn.lock b/yarn.lock
index 8e43f086e..497898c23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7578,7 +7578,7 @@ debug@~4.1.0:
dependencies:
ms "^2.1.1"
-debuglog@*, debuglog@^1.0.1:
+debuglog@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
@@ -12380,7 +12380,7 @@ import-lazy@^4.0.0:
resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153"
integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==
-imurmurhash@*, imurmurhash@^0.1.4:
+imurmurhash@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
@@ -14078,11 +14078,6 @@ lodash._baseflatten@^3.0.0:
lodash.isarguments "^3.0.0"
lodash.isarray "^3.0.0"
-lodash._baseindexof@*:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c"
- integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw=
-
lodash._baseuniq@~4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"
@@ -14091,16 +14086,11 @@ lodash._baseuniq@~4.6.0:
lodash._createset "~4.0.0"
lodash._root "~3.0.0"
-lodash._bindcallback@*, lodash._bindcallback@^3.0.0:
+lodash._bindcallback@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4=
-lodash._cacheindexof@*:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92"
- integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI=
-
lodash._createassigner@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
@@ -14110,19 +14100,12 @@ lodash._createassigner@^3.0.0:
lodash._isiterateecall "^3.0.0"
lodash.restparam "^3.0.0"
-lodash._createcache@*:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093"
- integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM=
- dependencies:
- lodash._getnative "^3.0.0"
-
lodash._createset@~4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY=
-lodash._getnative@*, lodash._getnative@^3.0.0:
+lodash._getnative@^3.0.0:
version "3.9.1"
resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=
@@ -14340,7 +14323,7 @@ lodash.pick@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=
-lodash.restparam@*, lodash.restparam@^3.0.0:
+lodash.restparam@^3.0.0:
version "3.6.1"
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=