diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js new file mode 100644 index 000000000..d68638655 --- /dev/null +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; + +export default class ScopesScopeProjectsTargetsIndexController extends Controller { + // =services + + @service confirm; + @service ipc; + @service router; + @service session; + @service store; + + // =methods + + /** + * Quick connect method used to call main connect method and handle + * connection errors unique to this route + * @param {TargetModel} target + */ + @action + async quickConnect(target) { + try { + await this.connect(target); + } catch (error) { + this.confirm + .confirm(error.message, { isConnectError: true }) + // Retry + .then(() => this.quickConnect(target)); + } + } + + /** + * Establish a session to current target. + * @param {TargetModel} target + * @param {HostModel} host + */ + @action + @loading + async connect(target, host) { + // Check for CLI + const cliExists = await this.ipc.invoke('cliExists'); + if (!cliExists) throw new Error('Cannot find Boundary CLI.'); + + const options = { + target_id: target.id, + token: this.session.data.authenticated.token, + }; + + if (host) options.host_id = host.id; + + // Create target session + const connectionDetails = await this.ipc.invoke('connect', options); + + // Associate the connection details with the session + let session; + const { session_id, address, port, credentials } = connectionDetails; + try { + session = await this.store.findRecord('session', session_id); + } catch (error) { + /** + * if the user cannot read or fetch the session we add the important + * information returned from the connect command to allow the user + * to still continue their work with the information they need + */ + this.store.pushPayload('session', { + sessions: [ + { + id: session_id, + proxy_address: address, + proxy_port: port, + target_id: target.id, + }, + ], + }); + session = this.store.peekRecord('session', session_id); + } + + // Flag the session has been open in the desktop client + session.started_desktop_client = true; + /** + * Update the session record with proxy information from the CLI + * 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. + */ + session.proxy_address = address; + session.proxy_port = port; + if (credentials) { + credentials.forEach((cred) => session.addCredential(cred)); + } + + this.router.transitionTo( + 'scopes.scope.projects.sessions.session', + session_id + ); + } +} diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js index 5d2d19954..df578b3af 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/target.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/target.js @@ -6,16 +6,12 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { loading } from 'ember-loading'; export default class ScopesScopeProjectsTargetsTargetRoute extends Route { // =services @service store; @service confirm; - @service ipc; - @service router; - @service session; // =attributes @@ -98,6 +94,34 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route { return true; } + /** + * Connect method that calls parent connect method and handles + * connection errors unique to this route + * @param {TargetModel} target + * @param {HostModel} host + */ + @action + async connect(target, host) { + /* eslint-disable-next-line ember/no-controller-access-in-routes */ + const parentController = this.controllerFor( + 'scopes.scope.projects.targets.index' + ); + try { + await parentController.connect(target, host); + } catch (error) { + this.isConnectionError = true; + this.confirm + .confirm(error.message, { isConnectError: true }) + // Retry + .then(() => this.connect(target, host)) + .catch(() => { + // Reset the flag as this was user initiated and we're not + // in a transition or have a host modal open + this.isConnectionError = false; + }); + } + } + /** * Establish a session to current target. * @param {TargetModel} target @@ -107,6 +131,7 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route { @action async hostConnect(target, host) { await this.connect(target, host); + if (this.isConnectionError) { /* eslint-disable-next-line ember/no-controller-access-in-routes */ const controller = this.controllerFor( @@ -120,9 +145,9 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route { /** * Determine if we show host modal or quick connect based on target attributes. * @param {TargetModel} model + * @param {function} toggleModal */ @action - @loading async preConnect(model, toggleModal) { /** * if hosts length is 1 or less we will try to @@ -136,83 +161,4 @@ export default class ScopesScopeProjectsTargetsTargetRoute extends Route { toggleModal(true); } } - - /** - * Establish a session to current target. - * @param {TargetModel} model - * @param {HostModel} host - */ - @action - @loading - async connect(model, host) { - // TODO: Connect: move this logic into the target model - try { - // Check for CLI - const cliExists = await this.ipc.invoke('cliExists'); - if (!cliExists) throw new Error('Cannot find Boundary CLI.'); - - const options = { - target_id: model.id, - token: this.session.data.authenticated.token, - }; - - if (host) options.host_id = host.id; - - // Create target session - const connectionDetails = await this.ipc.invoke('connect', options); - - // Associate the connection details with the session - let session; - const { session_id, address, port, credentials } = connectionDetails; - try { - session = await this.store.findRecord('session', session_id); - } catch (error) { - /** - * if the user cannot read or fetch the session we add the important - * information returned from the connect command to allow the user - * to still continue their work with the information they need - */ - this.store.pushPayload('session', { - sessions: [ - { - id: session_id, - proxy_address: address, - proxy_port: port, - target_id: model.id, - }, - ], - }); - session = this.store.peekRecord('session', session_id); - } - - // Flag the session has been open in the desktop client - session.started_desktop_client = true; - /** - * Update the session record with proxy information from the CLI - * 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. - */ - session.proxy_address = address; - session.proxy_port = port; - if (credentials) { - credentials.forEach((cred) => session.addCredential(cred)); - } - - await this.router.transitionTo( - 'scopes.scope.projects.sessions.session', - session_id - ); - } catch (e) { - this.isConnectionError = true; - this.confirm - .confirm(e.message, { isConnectError: true }) - // Retry - .then(() => this.connect(model, host)) - .catch(() => { - // Reset the flag as this was user initiated and we're not - // in a transition or have a host modal open - this.isConnectionError = false; - }); - } - } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs index 5a1eaa5b7..9cba3074e 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs @@ -160,14 +160,23 @@ {{#if (can 'connect target' B.data)}} - + {{#if (can 'read target' B.data)}} + + {{else}} + + {{/if}} {{/if}} diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index 19e991ea1..49d19e92f 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -29,10 +29,15 @@ module('Acceptance | projects | targets', function (hooks) { let getTargetCount; const ROSE_APP_STATE_TITLE = '.rose-message-title'; + const APP_STATE_TITLE = '.hds-application-state__title'; const TARGET_DETAILS_ROUTE_NAME = 'scopes.scope.projects.targets.target.index'; const ROSE_DIALOG_MODAL = '.rose-dialog-error'; const CHOOSE_HOST_MODAL = '[data-test-host-modal]'; + const ROSE_DIALOG_MODAL_BUTTONS = '.rose-dialog-footer button'; + const ROSE_DIALOG_RETRY_BUTTON = '.rose-dialog footer .rose-button-primary'; + const ROSE_DIALOG_CANCEL_BUTTON = + '.rose-dialog footer .rose-button-secondary'; const instances = { scopes: { @@ -225,7 +230,7 @@ module('Acceptance | projects | targets', function (hooks) { .doesNotExist(); }); - test('user is redirected to target details page when unable to connect from list view', async function (assert) { + test('user is redirected to target details page when unable to connect from list view if they have read and authorize-session permissions', async function (assert) { assert.expect(3); stubs.ipcService.withArgs('cliExists').returns(true); stubs.ipcService.withArgs('connect').rejects(); @@ -240,4 +245,47 @@ module('Acceptance | projects | targets', function (hooks) { assert.dom(CHOOSE_HOST_MODAL).doesNotExist(); assert.strictEqual(currentRouteName(), TARGET_DETAILS_ROUTE_NAME); }); + + test('user can connect without target read permissions', async function (assert) { + assert.expect(2); + instances.target.authorized_actions = + instances.target.authorized_actions.filter((item) => item !== 'read'); + stubs.ipcService.withArgs('cliExists').returns(true); + stubs.ipcService.withArgs('connect').returns({ + session_id: instances.session.id, + address: 'a_123', + port: 'p_123', + protocol: 'tcp', + }); + + await visit(urls.targets); + + await click(`[data-test-targets-connect-button="${instances.target.id}"]`); + + assert.strictEqual( + currentURL(), + `${urls.projects}/sessions/${instances.session.id}` + ); + assert.dom(APP_STATE_TITLE).hasText('Connected'); + }); + + test('user can retry connect without target read permissions', async function (assert) { + assert.expect(5); + instances.target.authorized_actions = + instances.target.authorized_actions.filter((item) => item !== 'read'); + stubs.ipcService.withArgs('cliExists').returns(true); + stubs.ipcService.withArgs('connect').rejects(); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + + await visit(urls.targets); + + await click(`[data-test-targets-connect-button="${instances.target.id}"]`); + + assert.strictEqual(currentURL(), urls.targets); + assert.dom(ROSE_DIALOG_MODAL).exists(); + assert.dom(ROSE_DIALOG_MODAL_BUTTONS).exists({ count: 2 }); + assert.dom(ROSE_DIALOG_RETRY_BUTTON).hasText('Retry'); + assert.dom(ROSE_DIALOG_CANCEL_BUTTON).hasText('Cancel'); + }); });