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}}
+