diff --git a/addons/api/addon/models/session.js b/addons/api/addon/models/session.js index bde91cf3c..c86a4d2b7 100644 --- a/addons/api/addon/models/session.js +++ b/addons/api/addon/models/session.js @@ -1,10 +1,14 @@ import GeneratedSessionModel from '../generated/models/session'; import { computed } from '@ember/object'; +import { attr } from '@ember-data/model'; export default class SessionModel extends GeneratedSessionModel { // =attributes + @attr('string', { readOnly: true }) proxy_address; + @attr('number', { readOnly: true }) proxy_port; + /** * @type {boolean} */ @@ -13,6 +17,17 @@ export default class SessionModel extends GeneratedSessionModel { return this.status?.match(/(active)|(pending)/i); } + /** + * The full proxy address and port if address exists, otherwise null. + * @type {?string} + */ + @computed('proxy_address', 'proxy_port') + get proxy() { + return this.proxy_address + ? `${this.proxy_address}:${this.proxy_port}` + : null; + } + // =methods /** diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index bbd5b1637..5ae912f69 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -3,9 +3,12 @@ import config from '../config/environment'; import { authHandler, deauthHandler } from './route-handlers/auth'; import { pickRandomStatusString } from './factories/session'; import { Response } from 'miragejs'; +import initializeMockIPC from './scenarios/ipc'; export default function() { + initializeMockIPC(this); + // make this `http://localhost:8080`, for example, if your API is on a different server // this.urlPrefix = ''; // make this `/api`, for example, if your API is namespaced diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js new file mode 100644 index 000000000..bad3194c8 --- /dev/null +++ b/addons/api/mirage/scenarios/ipc.js @@ -0,0 +1,76 @@ +import Ember from 'ember'; +import config from 'ember-get-config'; +import { random } from 'faker'; + +export default function initializeMockIPC (server) { + /** + * We strive to make this application runnable in a regular web browser, since + * it is a convenient environment for development and testing. But only an + * Electron environment has true IPC. Outside of Electron, we mock the handling + * of the message-based IPC requests originating from the + * renderer (the Ember app). + */ + class MockIPC { + async invoke(method, payload) { + return await this[method](payload); + } + + origin = null; + + getOrigin() { + return this.origin; + } + + setOrigin(origin) { + this.origin = origin; + return this.origin; + } + + cliExists() { + return true; + } + + /** + * Creates a new session instance associated with the specified target. + * @param connect_details + * @param + */ + connect({ target_id }) { + const target = server.schema.targets.find(target_id); + const { scope, type } = target; + const newSession = server.schema.sessions.create({ + scope, + target, + userId: 'authenticateduser', + type, + created_time: new Date().toISOString() + }); + return { + address: window.location.hostname, + port: random.number(), + protocol: type, + session_id: newSession.id + }; + } + } + + /** + * Establishes a mock IPC handler, which mimics the behavior of the Electron + * main process, and routes messages to it in a way similar to our + * preload.js script. + * + * Initializes mock IPC only in a non-Electron non-testing context. + */ + if (!config.isElectron && !Ember.testing) { + const mockIPC = new MockIPC(); + window.addEventListener('message', async function (event) { + if (event.origin !== window.location.origin) return; + const { method, payload } = event?.data ?? {}; + if (method) { + const response = await mockIPC.invoke(method, payload); + event.ports[0].postMessage(response); + } + }); + } + +} diff --git a/addons/api/tests/unit/models/session-test.js b/addons/api/tests/unit/models/session-test.js index 4100aeac2..d4477c5d9 100644 --- a/addons/api/tests/unit/models/session-test.js +++ b/addons/api/tests/unit/models/session-test.js @@ -26,4 +26,15 @@ module('Unit | Model | session', function(hooks) { }); assert.ok(model.isCancelable); }); + + test('it computes a proxy attribute', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('session'); + assert.equal(model.proxy, null); + model.setProperties({ + proxy_address: 'localhost', + proxy_port: '12345' + }); + assert.equal(model.proxy, 'localhost:12345'); + }); }); diff --git a/addons/core/translations/form/en-us.yaml b/addons/core/translations/form/en-us.yaml index 459380e62..dfd2362a4 100644 --- a/addons/core/translations/form/en-us.yaml +++ b/addons/core/translations/form/en-us.yaml @@ -49,3 +49,5 @@ grant_scope: origin: label: Origin URL description: The Boundary URL to which to connect (e.g. protocol://host:port) +proxy: + label: Proxy diff --git a/ui/desktop/app/components/copyable/index.hbs b/ui/desktop/app/components/copyable/index.hbs new file mode 100644 index 000000000..618f3315b --- /dev/null +++ b/ui/desktop/app/components/copyable/index.hbs @@ -0,0 +1,30 @@ + + + {{#if this.copied}} + + {{yield}} + + {{else}} + + {{yield}} + + {{/if}} + + {{#if this.isClipboardLibrarySupported}} + + {{this.actionText}} + + {{/if}} + diff --git a/ui/desktop/app/components/copyable/index.js b/ui/desktop/app/components/copyable/index.js new file mode 100644 index 000000000..17652e911 --- /dev/null +++ b/ui/desktop/app/components/copyable/index.js @@ -0,0 +1,78 @@ +import Component from '@glimmer/component'; +import ClipboardJS from 'clipboard'; +import { action } from '@ember/object'; +import { generateComponentID } from 'rose/utilities/component-auto-id'; +import { task, timeout } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; + +export default class CopyableComponent extends Component { + + // =attributes + + id = generateComponentID(); + copyableButtonId = `copyable-button-${this.id}`; + + actionIcon = 'copy-action'; + successIcon = 'copy-success'; + actionText = this.args.buttonText; + successText = this.args.acknowledgeText; + + @tracked copied = false; + + clipboard = null; + + /** + * A Ember Concurrency-based task that updates copy icon to a success state + * and back after 1 second. This icon flash indicates success on + * copy to clipboard action. + * + * NOTE: tasks are sort of attributes and sort of methods, but they are not + * language-level constructs. Thus we annotate this task as if it + * is an attribute. + * @type {Task} + */ + /* istanbul ignore next */ + @task(function * () { + this.copied = true; + yield timeout(1000); + this.copied = false; + }).drop() confirmCopyTimer; + + /** + * Checks for ClipboardJS support. + * @return {boolean} + */ + get isClipboardLibrarySupported() { + return ClipboardJS.isSupported(); + } + + // =actions + + /** + * Initialize ClipboardJS library and register for it's events. + */ + @action + register() { + this.destroy(); + /* istanbul ignore next */ + this.clipboard = new ClipboardJS(`#${this.copyableButtonId}`, { + text: () => this.args.text, + }); + /* istanbul ignore next */ + this.clipboard.on('success', (clipboardEvent) => { + clipboardEvent.clearSelection(); + this.confirmCopyTimer.perform(); + }); + } + + /** + * Destroy ClipboardJS library instance and cancel icon timer. + */ + @action + destroy() { + this.clipboard?.destroy(); + this.clipboard = null; + this.confirmCopyTimer.cancelAll(); + } + +} diff --git a/ui/desktop/app/initializers/ipc.js b/ui/desktop/app/initializers/ipc.js deleted file mode 100644 index 74e9d8545..000000000 --- a/ui/desktop/app/initializers/ipc.js +++ /dev/null @@ -1,52 +0,0 @@ -import Ember from 'ember'; -import config from '../config/environment'; - -/** - * We strive to make this application runnable in a regular web browser, since - * it is a convenient environment for development and testing. But only an - * Electron environment has true IPC. Outside of Electron, we mock the handling - * of the message-based IPC requests originating from the - * renderer (the Ember app). - */ -class MockIPC { - async invoke(method, payload) { - console.log('MockIPC:', method, payload); - return await this[method](payload); - } - - origin = null; - - getOrigin() { - return this.origin; - } - - setOrigin(origin) { - this.origin = origin; - return this.origin; - } - - connect() {} -} - -/** - * Establishes a mock IPC handler, which mimics the behavior of the Electron - * main process, and routes messages to it in a way similar to our - * preload.js script. - * - * Initializes mock IPC only in a non-Electron non-testing context. - */ -export function initialize() { - if (!config.isElectron && !Ember.testing) { - const mockIPC = new MockIPC(); - window.addEventListener('message', async function (event) { - if (event.origin !== window.location.origin) return; - const { method, payload } = event?.data ?? {}; - if (method) { - const response = await mockIPC.invoke(method, payload); - event.ports[0].postMessage(response); - } - }); - } -} - -export default { initialize }; diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets.js b/ui/desktop/app/routes/scopes/scope/projects/targets.js index 9165e112f..e251e181a 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets.js @@ -60,8 +60,30 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { addr: this.origin.rendererOrigin }); + // Associate the connection details with the session + const { session_id, address, port } = connectionDetails; + await this.store.findRecord('session', session_id); + + // Update the session record with proxy information from the CLI + // This is read-only information that shouldn't dirty the session, + // so we use store.push here. In the future, it may make sense to push + // this off to the API so that we don't have to manually persist the + // proxy details. + this.store.push({ + data: { + id: session_id, + type: 'session', + attributes: { + proxy_address: address, + proxy_port: port + } + } + }); + // Show the user a modal with basic connection info. + // We don't await because this modal is purely informational. this.confirm.confirm(connectionDetails, { isConnectSuccess: true }); + } catch(e) { this.confirm.confirm(e.message, { isConnectError: true }) // Retry diff --git a/ui/desktop/app/styles/app.scss b/ui/desktop/app/styles/app.scss index 14d7233c2..d361d91f6 100644 --- a/ui/desktop/app/styles/app.scss +++ b/ui/desktop/app/styles/app.scss @@ -167,7 +167,7 @@ 0% { background-color: var(--action-subtler-1); } - + 100% { background-color: transparent; } diff --git a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs index d0847dbb3..f6d0b70e3 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/index.hbs @@ -13,6 +13,7 @@ {{t "form.id.label"}} {{t "resources.target.title"}} + {{t "form.proxy.label"}} {{t "form.started.label"}} {{t "form.status.label"}} @@ -28,6 +29,30 @@ {{sessionAggregate.session.id}} {{sessionAggregate.target.displayName}} + + {{!-- + Two if's here because: + 1) Local proxy info is only relevant if the session is + active or pending (aka isCancelable). Once the session + is canceled or in the process thereof, the local proxy + may no longer be used. + 2) The session actually has proxy information associated + with it. It's possible for the user to start sessions + from other devices (or even another tab), in which case we + won't have the local proxy info available here. + --}} + {{#if sessionAggregate.session.isCancelable}} + {{#if sessionAggregate.session.proxy}} + + {{sessionAggregate.session.proxy}} + + {{/if}} + {{/if}} +