feat: 🎸 rdp form - create and update

This commit is contained in:
Priya Patel authored and Dharini committed 2025-09-03 16:01:25 -07:00
1 parent d2d351c9f7
commit 30a5f2a701
12 files changed
+372 -15

No files matched your search

+2 -1
View File
@@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility {
*/
get canAddInjectedApplicationCredentialSources() {
return (
this.model.isSSH && this.hasAuthorizedAction('add-credential-sources')
(this.model.isSSH || this.model.isRDP) &&
this.hasAuthorizedAction('add-credential-sources')
);
}
/**
@@ -750,6 +750,7 @@ target:
default_client_port:
label: Default Client Port
help: The local proxy port on which to listen by default when a session is started on a client.
rdp-windows-error: 'Note: Windows OS prevents port 3389 from being used.'
sidebar:
label: Session Recording
help: Record SSH sessions on this target and save them to a storage bucket.
@@ -812,6 +813,10 @@ target:
add:
title: Add Injected Application Credential Sources
description: Select injected Application credential sources to assign to this target.
alert:
title: Cannot connect to this target without injected application credentials
description: Injected application credentials are used by Boundary to sign in to remote resources without showing them to the end-user.
action: Add injected application credentials
workers:
description: You can customize how your workers route traffic to this target by setting up a filter that matches specific worker tags. If your target is in a private network, we recommend setting up an egress filter to specify a worker inside the network so that Boundary can access the host. Ingress and egress filters are allowed to select the same worker.
messages:
@@ -3,6 +3,31 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.showInjectedApplicationCredentialAlert}}
<Hds::Alert
data-test-injected-application-credential-alert
class='injected-app-credential-alert'
@type='inline'
@color='critical'
as |A|
>
<A.Title>{{t
'resources.target.injected-application-credential-source.messages.alert.title'
}}</A.Title>
<A.Description>{{t
'resources.target.injected-application-credential-source.messages.alert.description'
}}</A.Description>
<A.Button
@color='secondary'
@text={{t
'resources.target.injected-application-credential-source.messages.alert.action'
}}
@route='scopes.scope.targets.target.add-injected-application-credential-sources'
@model={{@model}}
/>
</Hds::Alert>
{{/if}}
<Rose::Form
class={{unless @model.isNew 'full-width'}}
@onSubmit={{@submit}}
@@ -52,7 +77,7 @@
{{/if}}
</Hds::Form::Textarea::Field>
{{#if (and @model.isNew (feature-flag 'ssh-target'))}}
{{#if this.showTargetTypeRadioGroup}}
<Hds::Form::RadioCard::Group
@name={{t 'form.type.label'}}
@alignment='center'
@@ -144,22 +169,28 @@
<Hds::Form::TextInput::Field
@isOptional={{true}}
@value={{@model.default_client_port}}
@isInvalid={{@model.errors.default_client_port}}
@isInvalid={{this.showDefaultClientPortError}}
@type='text'
name='default_client_port'
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'default_client_port')}}
{{on 'blur' this.handleDefaultClientPortBlur}}
as |F|
>
<F.Label>{{t 'resources.target.form.default_client_port.label'}}</F.Label>
<F.HelperText>{{t
'resources.target.form.default_client_port.help'
}}</F.HelperText>
{{#if @model.errors.default_client_port}}
{{#if this.showDefaultClientPortError}}
<F.Error as |E|>
{{#each @model.errors.default_client_port as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
{{#if this.showRDPWindowsDefaultClientPortError}}
<E.Message data-test-error-message-default-client-port-rdp-windows>
{{t 'resources.target.form.default_client_port.rdp-windows-error'}}
</E.Message>
{{/if}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
@@ -4,17 +4,33 @@
*/
import Component from '@glimmer/component';
import { TYPES_TARGET } from 'api/models/target';
import {
TYPE_TARGET_TCP,
TYPE_TARGET_RDP,
TYPE_TARGET_SSH,
} from 'api/models/target';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
// NOTE: this is all a temporary solution till we have a resource type helper.
const types = [...TYPES_TARGET].reverse();
const icons = {
ssh: 'terminal-screen',
tcp: 'network',
rdp: 'monitor',
};
const RDP_WINDOWS_INVALID_DEFAULT_CLIENT_PORT = '3389';
export default class FormTargetComponent extends Component {
// attributes
/**
* Flag to show the RDP Windows default client port error.
* This is set to true when the default client port is '3389' for RDP targets.
* It is used to indicate that the default client port is not recommended for Windows RDP targets.
* @type {boolean}
*/
@tracked showRDPWindowsDefaultClientPortError = false;
// =services
@service features;
@@ -24,6 +40,10 @@ export default class FormTargetComponent extends Component {
* @type {object}
*/
get typeMetas() {
const types = [TYPE_TARGET_TCP];
if (this.isSSHEnabled) types.push(TYPE_TARGET_SSH);
if (this.isRDPEnabled) types.push(TYPE_TARGET_RDP);
return types.map((type) => ({
type,
icon: icons[type],
@@ -38,4 +58,61 @@ export default class FormTargetComponent extends Component {
get icon() {
return icons[this.args.model.type];
}
get isRDPEnabled() {
return this.features.isEnabled('rdp-target');
}
get isSSHEnabled() {
return this.features.isEnabled('ssh-target');
}
/**
* Checks if the default client port error should be shown.
* This is true if either the default client port `3389` error is shown or there is an error on the default client port field.
* @type {boolean}
*/
get showDefaultClientPortError() {
return (
this.showRDPWindowsDefaultClientPortError ||
this.args.model.get('errors.default_client_port')
);
}
/**
* Checks if the injected application credential alert should be shown for SSH and RDP targets.
* @type {boolean}
*/
get showInjectedApplicationCredentialAlert() {
return (
!this.args.model.isNew &&
this.args.model.injected_application_credential_source_ids.length === 0 &&
this.args.model.type !== TYPE_TARGET_TCP
);
}
/**
* Checks if the target type radio group should be displayed.
* This is true if either the 'rdp-target' or 'ssh-target' feature is enabled.
* @returns {boolean}
* @type {boolean}
*/
get showTargetTypeRadioGroup() {
return (this.isRDPEnabled || this.isSSHEnabled) && this.args.model.isNew;
}
// =actions
/**
* Validates the default client port for the target.
* If the target type is RDP and the default client port is set to '3389', it sets the error flag.
* @param {Event} event
*/
@action
handleDefaultClientPortBlur(event) {
const defaultClientPort = event.target.value;
this.showRDPWindowsDefaultClientPortError =
defaultClientPort === RDP_WINDOWS_INVALID_DEFAULT_CLIENT_PORT &&
this.args.model.type === TYPE_TARGET_RDP;
}
}
@@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Form::Target::Details
@model={{@model}}
@defaultPort='3389'
@submit={{@submit}}
@cancel={{@cancel}}
@changeType={{@changeType}}
@globalScope={{@globalScope}}
/>
@@ -16,7 +16,7 @@
<nav.link @route='scopes.scope.targets.target.brokered-credential-sources'>
{{t 'resources.target.brokered-credential-source.title_plural'}}
</nav.link>
{{#if @model.isSSH}}
{{#if (or @model.isSSH @model.isRDP)}}
<nav.link
@route='scopes.scope.targets.target.injected-application-credential-sources'
>
@@ -5,7 +5,11 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
import {
TYPE_TARGET_TCP,
TYPE_TARGET_SSH,
TYPE_TARGET_RDP,
} from 'api/models/target';
export default class ScopesScopeTargetsNewRoute extends Route {
// =services
@@ -47,11 +51,16 @@ export default class ScopesScopeTargetsNewRoute extends Route {
let name, description;
const scopeModel = this.modelFor('scopes.scope');
// default type is SSH if the feature is enabled, otherwise TCP
if (!type)
type = this.features.isEnabled('ssh-target')
? TYPE_TARGET_SSH
: TYPE_TARGET_TCP;
// default type is RDP if the feature is enabled, or is SSH if feature enabled otherwise TCP
if (!type) {
if (this.features.isEnabled('rdp-target')) {
type = TYPE_TARGET_RDP;
} else if (this.features.isEnabled('ssh-target')) {
type = TYPE_TARGET_SSH;
} else {
type = TYPE_TARGET_TCP;
}
}
if (this.currentModel?.isNew) {
({ name, description } = this.currentModel);
+5
View File
@@ -1020,3 +1020,8 @@
.dropdown-checkbox-list {
margin-bottom: 1rem;
}
// target
.injected-app-credential-alert {
margin-bottom: 1rem;
}
@@ -10,13 +10,18 @@ import {
fillIn,
getContext,
visit,
blur,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
import { setupSqlite } from 'api/test-support/helpers/sqlite';
import {
TYPE_TARGET_TCP,
TYPE_TARGET_SSH,
TYPE_TARGET_RDP,
} from 'api/models/target';
import { setupIntl } from 'ember-intl/test-support';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import * as selectors from './selectors';
@@ -32,6 +37,7 @@ module('Acceptance | targets | create', function (hooks) {
let getTCPTargetCount;
let getSSHTargetCount;
let featuresService;
let getRDPTargetCount;
const instances = {
scopes: {
@@ -48,6 +54,7 @@ module('Acceptance | targets | create', function (hooks) {
newTarget: null,
newTCPTarget: null,
newSSHTarget: null,
newRDPTarget: null,
};
hooks.beforeEach(async function () {
@@ -72,12 +79,15 @@ module('Acceptance | targets | create', function (hooks) {
urls.newTarget = `${urls.targets}/new`;
urls.newTCPTarget = `${urls.targets}/new?type=tcp`;
urls.newSSHTarget = `${urls.targets}/new?type=ssh`;
urls.newRDPTarget = `${urls.targets}/new?type=rdp`;
// Generate resource counter
getTargetCount = () => this.server.schema.targets.all().models.length;
getSSHTargetCount = () =>
this.server.schema.targets.where({ type: TYPE_TARGET_SSH }).models.length;
getTCPTargetCount = () =>
this.server.schema.targets.where({ type: TYPE_TARGET_TCP }).models.length;
getRDPTargetCount = () =>
this.server.schema.targets.where({ type: TYPE_TARGET_RDP }).models.length;
await authenticateSession({});
});
@@ -505,4 +515,115 @@ module('Acceptance | targets | create', function (hooks) {
);
assert.strictEqual(currentURL(), urls.targets);
});
test('defaults to type `rdp` when no query param provided and rdp feature is enabled', async function (assert) {
featuresService.enable('rdp-target');
await visit(urls.targets);
await click(commonSelectors.HREF(urls.newTarget));
assert.dom(selectors.FIELD_TYPE_CHECKED).hasValue(TYPE_TARGET_RDP);
});
test('cannot navigate to new RDP targets route when rdp feature is disabled', async function (assert) {
await visit(urls.targets);
await click(commonSelectors.HREF(urls.newTarget));
assert.false(featuresService.isEnabled('rdp-target'));
assert.true(
instances.scopes.project.authorized_collection_actions.targets.includes(
'create',
),
);
assert.dom(selectors.FIELD_INFO).isVisible({ count: 1 });
assert.dom(selectors.FIELD_INFO_LABEL).includesText('TCP');
});
test('default port is not marked required for RDP targets', async function (assert) {
featuresService.enable('rdp-target');
await visit(urls.targets);
await click(commonSelectors.HREF(urls.newTarget));
assert.dom(selectors.FIELD_DEFAULT_PORT_LABEL).includesText('Optional');
});
test('can cancel create new RDP target', async function (assert) {
featuresService.enable('rdp-target');
const targetCount = getTargetCount();
const rdpTargetCount = getRDPTargetCount();
await visit(urls.targets);
await click(commonSelectors.HREF(urls.newTarget));
await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE);
await click(selectors.FIELD_TYPE_VALUE('rdp'));
await click(commonSelectors.CANCEL_BTN);
assert.strictEqual(currentURL(), urls.targets);
assert.strictEqual(getTargetCount(), targetCount);
assert.strictEqual(getRDPTargetCount(), rdpTargetCount);
});
test('can create a type `rdp` target', async function (assert) {
featuresService.enable('rdp-target');
const targetCount = getTargetCount();
const rdpTargetCount = getRDPTargetCount();
await visit(urls.targets);
await click(commonSelectors.HREF(urls.newTarget));
await click(selectors.FIELD_TYPE_VALUE('rdp'));
await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE);
await fillIn(selectors.FIELD_DEFAULT_CLIENT_PORT, '3389');
await blur(selectors.FIELD_DEFAULT_CLIENT_PORT);
assert
.dom(selectors.FIELD_DEFAULT_CLIENT_PORT_RDP_WINDOWS_ERROR)
.hasText('Note: Windows OS prevents port 3389 from being used.');
await click(commonSelectors.SAVE_BTN);
assert.strictEqual(getRDPTargetCount(), rdpTargetCount + 1);
assert.strictEqual(getTargetCount(), targetCount + 1);
assert.strictEqual(
this.server.schema.targets.all().models[getTargetCount() - 1].name,
commonSelectors.FIELD_NAME_VALUE,
);
assert.strictEqual(
this.server.schema.targets.all().models[getTargetCount() - 1].attributes
.default_client_port,
3389,
);
});
test('saving a new RDP target with invalid fields displays error messages', async function (assert) {
this.server.post('/targets', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'The request was invalid.',
details: {
request_fields: [
{
name: 'name',
description: 'Name is required.',
},
],
},
},
);
});
await visit(urls.newRDPTarget);
await click(commonSelectors.SAVE_BTN);
assert
.dom(commonSelectors.ALERT_TOAST_BODY)
.hasText('The request was invalid.');
assert.dom(selectors.FIELD_NAME_ERROR).hasText('Name is required.');
});
});
@@ -24,6 +24,9 @@ export const FIELD_ENABLE_RECORDING_TOGGLE =
'[name=target-enable-session-recording]';
export const FIELD_ADD_NEW_STORAGE_BUCKET_LINK =
'.enable-session-recording-toggle .hds-link-standalone';
export const FIELD_DEFAULT_CLIENT_PORT_RDP_WINDOWS_ERROR =
'[data-test-error-message-default-client-port-rdp-windows';
export const FIELD_DEFAULT_CLIENT_PORT = '[name="default_client_port"]';
export const NEW_TARGET_BTN = '[data-test-new-target-button]';
export const MANAGE_DROPDOWN = '[data-test-manage-targets-dropdown] button';
@@ -68,3 +71,7 @@ export const WORKERS_ACCORDION_DROPDOWN = (name) =>
`[data-test-target-${name}-workers-accordion-item] .hds-accordion-item__button`;
export const WORKERS_ACCORDION_DROPDOWN_LINK = (name) =>
`[data-test-target-${name}-workers-accordion-item] a`;
export const ALERT_INJECTED_APPLICATION_CREDENTIAL =
'[data-test-injected-application-credential-alert]';
export const ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN =
'[data-test-injected-application-credential-alert] .hds-alert__actions a';
@@ -14,6 +14,7 @@ import { authenticateSession } from 'ember-simple-auth/test-support';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import * as selectors from './selectors';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { TYPE_TARGET_RDP, TYPE_TARGET_SSH } from 'api/models/target';
module('Acceptance | targets | update', function (hooks) {
setupApplicationTest(hooks);
@@ -30,11 +31,15 @@ module('Acceptance | targets | update', function (hooks) {
project: null,
},
target: null,
rdpTarget: null,
sshTarget: null,
};
const urls = {
projectScope: null,
targets: null,
target: null,
rdpTarget: null,
sshTarget: null,
};
hooks.beforeEach(async function () {
@@ -52,10 +57,22 @@ module('Acceptance | targets | update', function (hooks) {
instances.target = this.server.create('target', {
scope: instances.scopes.project,
});
instances.rdpTarget = this.server.create('target', {
type: TYPE_TARGET_RDP,
scope: instances.scopes.project,
injected_application_credential_source_ids: [],
});
instances.sshTarget = this.server.create('target', {
type: TYPE_TARGET_SSH,
scope: instances.scopes.project,
injected_application_credential_source_ids: [],
});
// Generate route URLs for resources
urls.projectScope = `/scopes/${instances.scopes.project.id}`;
urls.targets = `${urls.projectScope}/targets`;
urls.target = `${urls.targets}/${instances.target.id}`;
urls.rdpTarget = `${urls.targets}/${instances.rdpTarget.id}`;
urls.sshTarget = `${urls.targets}/${instances.sshTarget.id}`;
await authenticateSession({});
});
@@ -344,4 +361,53 @@ module('Acceptance | targets | update', function (hooks) {
);
assert.true(this.server.schema.targets.find(target.id).hostSets.length > 0);
});
test('can save changes to existing rdp target', async function (assert) {
featuresService.enable('rdp-target');
featuresService.enable('ssh-target');
await visit(urls.targets);
await click(commonSelectors.HREF(urls.rdpTarget));
assert
.dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL)
.exists('Injected application credential alert is displayed');
assert
.dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN)
.hasAttribute(
'href',
`${urls.rdpTarget}/add-injected-application-credential-sources`,
);
await click(commonSelectors.EDIT_BTN, 'Activate edit mode');
await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE);
await click(commonSelectors.SAVE_BTN);
assert.strictEqual(currentURL(), urls.rdpTarget);
assert.strictEqual(
this.server.schema.targets.where({ type: TYPE_TARGET_RDP }).models[0]
.name,
commonSelectors.FIELD_NAME_VALUE,
);
});
test('displays add injected credentials alert for ssh target', async function (assert) {
featuresService.enable('ssh-target');
await visit(urls.targets);
await click(commonSelectors.HREF(urls.sshTarget));
assert
.dom(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL)
.exists('Injected application credential alert is displayed');
await click(selectors.ALERT_INJECTED_APPLICATION_CREDENTIAL_ADD_BTN);
assert.strictEqual(
currentURL(),
`${urls.sshTarget}/add-injected-application-credential-sources`,
);
});
});
@@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module(
'Unit | Controller | scopes/scope/targets/target/index',
function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/targets/target/index',
);
assert.ok(controller);
assert.ok(controller.targets);
});
},
);