diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index 5493a66cc4..79537e28b5 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -40,3 +40,4 @@ fullscreen: Fullscreen clear-all-filters: Clear all filters learn-more: Learn more refresh: Refresh +remove-resources: Remove resources and save diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 36b8a30dd6..a73983759a 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -304,6 +304,7 @@ target: add-host-sources: Add Host Sources add-brokered-credential-sources: Add Brokered Credentials add-injected-application-credential-sources: Add Injected Application Credentials + remove-address: Remove address and save types: tcp: Generic TCP ssh: SSH @@ -314,6 +315,13 @@ target: type: label: Type help: Target type is the protocol with which end users should connect to this target. Choose Generic TCP for broad support of common protocols including RDP, K8s, many databases, and more. + target-address: + label: Target Address + help: Must be a valid IP address or DNS name. We recommend leaving this blank and using host catalogs and host sets instead if you want to use this target on multiple hosts. + questions: + delete-host-sources: + title: Remove associated host sources? + message: You have {numHostSources, plural, one {# host source} other {# host sources}} associated with this target. Adding an address will remove these host sources when you save your changes. host-source: title: Host Source title_plural: Host Sources @@ -327,6 +335,10 @@ target: add: title: Add Host Sources description: Select host sources to assign to this target. + questions: + delete-address: + title: Remove target address? + message: This target has an assigned address. Adding a host source will remove the assigned address from the target when you save your changes. brokered-credential-source: title: Brokered Credential title_plural: Brokered Credentials diff --git a/ui/admin/app/components/form/target/add-host-sets/index.hbs b/ui/admin/app/components/form/target/add-host-sets/index.hbs index 24dce95acb..b6c4f99f73 100644 --- a/ui/admin/app/components/form/target/add-host-sets/index.hbs +++ b/ui/admin/app/components/form/target/add-host-sets/index.hbs @@ -1,7 +1,7 @@ {{#if this.hasAvailableHostSets}} message) + async submit() { + const target = this.args.model; + + if (target.address && this.selectedHostSetIDs.length) { + try { + await this.confirm.confirm( + this.intl.t( + 'resources.target.host-source.questions.delete-address.message' + ), + { + title: + 'resources.target.host-source.questions.delete-address.title', + confirm: 'resources.target.actions.remove-address', + } + ); + } catch (e) { + // if the user denies, do nothing and return + return; + } + + target.address = null; + await target.save(); + } + + await this.args.submit(this.selectedHostSetIDs); } } diff --git a/ui/admin/app/components/form/target/details/index.hbs b/ui/admin/app/components/form/target/details/index.hbs index 1dbaa3d25b..74ec6d2028 100644 --- a/ui/admin/app/components/form/target/details/index.hbs +++ b/ui/admin/app/components/form/target/details/index.hbs @@ -1,5 +1,5 @@ {{/if}} + {{#if (feature-flag 'target-network-address')}} + + {{t 'resources.target.form.target-address.label'}} + {{t + 'resources.target.form.target-address.help' + }} + {{#if @model.errors.address}} + + {{#each @model.errors.address as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{/if}} + + + {{t + 'form.default_port.label' + }} + {{t 'form.default_port.help'}} + {{#if @model.errors.default_port}} + + {{#each @model.errors.default_port as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + - - {{t - 'form.default_port.label' - }} - {{t 'form.default_port.help'}} - {{#if @model.errors.default_port}} - - {{#each @model.errors.default_port as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - {{#if (feature-flag 'target-worker-filters-v2')}} message) + async submit() { + const target = this.args.model; + const numHostSources = target.host_sources?.length; + const address = target.address; + if (address && numHostSources) { + try { + await this.confirm.confirm( + this.intl.t( + 'resources.target.questions.delete-host-sources.message', + { numHostSources } + ), + { + title: 'resources.target.questions.delete-host-sources.title', + confirm: 'actions.remove-resources', + } + ); + } catch (e) { + // if the user denies, do nothing and return + return; + } + + await target.removeHostSources( + target.host_sources.map((hs) => hs.host_source_id) + ); + // After saving the host sources, the model gets reset to an empty address, + // so we need to update the address with the previous value before saving + target.address = address; + } + + await this.args.submit(); + } } diff --git a/ui/admin/app/routes/scopes/scope/targets.js b/ui/admin/app/routes/scopes/scope/targets.js index 50a536aa8b..da6a738939 100644 --- a/ui/admin/app/routes/scopes/scope/targets.js +++ b/ui/admin/app/routes/scopes/scope/targets.js @@ -55,7 +55,7 @@ export default class ScopesScopeTargetsRoute extends Route { */ @action @loading - @notifyError(({ message }) => message) + @notifyError(({ message }) => message, { catch: true }) @notifySuccess(({ isNew }) => isNew ? 'notifications.create-success' : 'notifications.save-success' ) diff --git a/ui/admin/app/templates/application.hbs b/ui/admin/app/templates/application.hbs index 9f44a55a7a..7b857e35b1 100644 --- a/ui/admin/app/templates/application.hbs +++ b/ui/admin/app/templates/application.hbs @@ -132,7 +132,11 @@ - {{t 'actions.ok'}} + {{if + confirmation.options.confirm + (t confirmation.options.confirm) + (t 'actions.ok') + }} {{t 'actions.cancel'}} diff --git a/ui/admin/tests/acceptance/targets/create-test.js b/ui/admin/tests/acceptance/targets/create-test.js index d27a71ef96..7f969df374 100644 --- a/ui/admin/tests/acceptance/targets/create-test.js +++ b/ui/admin/tests/acceptance/targets/create-test.js @@ -136,13 +136,13 @@ module('Acceptance | targets | create', function (hooks) { ); }); - test('defualt port is not marked required for SSH targets', async function (assert) { + test('default port is not marked required for SSH targets', async function (assert) { assert.expect(1); await visit(urls.newTarget); assert.dom('[data-test-default-port-label]').includesText('Optional'); }); - test('defualt port is marked required for TCP targets', async function (assert) { + test('default port is marked required for TCP targets', async function (assert) { assert.expect(1); await visit(urls.newTarget); await click('[value="tcp"]'); @@ -285,4 +285,31 @@ module('Acceptance | targets | create', function (hooks) { assert.dom('[role="alert"] div').hasText('The request was invalid.'); assert.dom('.hds-form-error__message').hasText('Name is required.'); }); + + test('can save address', async function (assert) { + assert.expect(2); + const targetCount = getTargetCount(); + await visit(urls.targets); + + await click(`[href="${urls.newTarget}"]`); + await fillIn('[name="name"]', 'random string'); + await fillIn('[name="address"]', '0.0.0.0'); + await click('[type="submit"]'); + + assert.strictEqual(getTargetCount(), targetCount + 1); + assert.strictEqual( + this.server.schema.targets.all().models[getTargetCount() - 1].address, + '0.0.0.0' + ); + }); + + test('address field does not exist when target network address feature is disabled', async function (assert) { + assert.expect(1); + featuresService.disable('target-network-address'); + await visit(urls.targets); + + await click(`[href="${urls.newTarget}"]`); + + assert.dom('[name="address"]').doesNotExist(); + }); }); diff --git a/ui/admin/tests/acceptance/targets/host-sources-test.js b/ui/admin/tests/acceptance/targets/host-sources-test.js index 1ad9982ebb..ed15e6d7ee 100644 --- a/ui/admin/tests/acceptance/targets/host-sources-test.js +++ b/ui/admin/tests/acceptance/targets/host-sources-test.js @@ -252,4 +252,79 @@ module('Acceptance | targets | host-sources', function (hooks) { assert.strictEqual(getTargetHostSetCount(), targetHostSetCount); assert.dom('[role="alert"] div').hasText('The request was invalid.'); }); + + test('saving host source with address brings up confirmation modal and removes address', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const target = this.server.create('target', { + scope: instances.scopes.project, + address: '0.0.0.0', + }); + const targetHostSetCount = this.server.schema.targets.find(target.id) + .hostSets.models.length; + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + '0.0.0.0' + ); + + const targetUrl = `${urls.targets}/${target.id}`; + await visit(targetUrl); + await click(`[href="${targetUrl}/host-sources"]`); + await click('.rose-layout-page-actions a', 'Click add host set'); + + await click('tbody label'); + await click('form [type="submit"]'); + + assert.dom('.rose-dialog').isVisible(); + await click('.rose-dialog-footer .rose-button-primary', 'Remove resources'); + + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + null + ); + assert.strictEqual( + this.server.schema.targets.find(target.id).hostSets.models.length, + targetHostSetCount + 1 + ); + }); + + test('saving host source with address brings up confirmation modal and can cancel', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const target = this.server.create('target', { + scope: instances.scopes.project, + address: '0.0.0.0', + }); + const targetHostSetCount = this.server.schema.targets.find(target.id) + .hostSets.models.length; + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + '0.0.0.0' + ); + + const targetUrl = `${urls.targets}/${target.id}`; + await visit(targetUrl); + await click(`[href="${targetUrl}/host-sources"]`); + await click('.rose-layout-page-actions a', 'Click add host set'); + + await click('tbody label'); + await click('form [type="submit"]'); + + assert.dom('.rose-dialog').isVisible(); + await click( + '.rose-dialog-footer .rose-button-secondary', + 'Remove resources' + ); + + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + '0.0.0.0' + ); + assert.strictEqual( + this.server.schema.targets.find(target.id).hostSets.models.length, + targetHostSetCount + ); + }); }); diff --git a/ui/admin/tests/acceptance/targets/update-test.js b/ui/admin/tests/acceptance/targets/update-test.js index 055fa4b087..f51484a47b 100644 --- a/ui/admin/tests/acceptance/targets/update-test.js +++ b/ui/admin/tests/acceptance/targets/update-test.js @@ -199,4 +199,89 @@ module('Acceptance | targets | update', function (hooks) { assert.dom('form [type="button"]').doesNotExist(); }); + + test('saving address with existing host sources brings up confirmation modal and removes host sources', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + this.server.createList( + 'host-catalog', + 8, + { scope: instances.scopes.project }, + 'withChildren' + ); + this.server.createList( + 'credential-store', + 3, + { scope: instances.scopes.project }, + 'withAssociations' + ); + const target = this.server.create( + 'target', + { scope: instances.scopes.project }, + 'withAssociations' + ); + assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0); + + const url = `${urls.targets}/${target.id}`; + await visit(urls.targets); + await click(`[href="${url}"]`); + + await click('form [type="button"]', 'Activate edit mode'); + await fillIn('[name="address"]', '0.0.0.0'); + await click('[type="submit"]'); + + assert.dom('.rose-dialog').isVisible(); + await click('.rose-dialog-footer .rose-button-primary', 'Remove resources'); + + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + '0.0.0.0' + ); + assert.strictEqual( + this.server.schema.targets.find(target.id).hostSets.length, + 0 + ); + }); + + test('saving address with existing host sources brings up confirmation modal and can cancel', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + this.server.createList( + 'host-catalog', + 8, + { scope: instances.scopes.project }, + 'withChildren' + ); + this.server.createList( + 'credential-store', + 3, + { scope: instances.scopes.project }, + 'withAssociations' + ); + const target = this.server.create( + 'target', + { scope: instances.scopes.project }, + 'withAssociations' + ); + assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0); + + const url = `${urls.targets}/${target.id}`; + await visit(urls.targets); + await click(`[href="${url}"]`); + + await click('form [type="button"]', 'Activate edit mode'); + await fillIn('[name="address"]', '0.0.0.0'); + await click('[type="submit"]'); + + assert.dom('.rose-dialog').isVisible(); + await click('.rose-dialog-footer .rose-button-secondary', 'Cancel'); + + assert.strictEqual( + this.server.schema.targets.find(target.id).address, + undefined + ); + assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0); + }); });