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}} {{/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=
{{@caption}}