diff --git a/addons/core/addon/decorators/notify.js b/addons/core/addon/decorators/notify.js
index de76391fa..ee6454b95 100644
--- a/addons/core/addon/decorators/notify.js
+++ b/addons/core/addon/decorators/notify.js
@@ -33,7 +33,8 @@ export function notifySuccess(notification) {
const text = intlService.t(candidateKey);
const value = await method.apply(this, arguments);
notifyService.success(text, {
- notificationType: 'success',
+ color: 'success',
+ title: intlService.t('states.success'),
dismiss: (flash) => flash.destroyMessage(),
});
return value;
@@ -99,7 +100,8 @@ export function notifyError(
const sticky = options.sticky === undefined ? true : options.sticky;
notifyService.danger(text, {
- notificationType: 'error',
+ color: 'critical',
+ title: intlService.t('states.error'),
sticky,
dismiss: (flash) => flash.destroyMessage(),
});
diff --git a/addons/core/translations/errors/en-us.yaml b/addons/core/translations/errors/en-us.yaml
index 525883d68..2ebc4b73c 100644
--- a/addons/core/translations/errors/en-us.yaml
+++ b/addons/core/translations/errors/en-us.yaml
@@ -27,3 +27,7 @@ onboarding-failed:
required-field: This is a required field.
client-agent-failed:
sessions: Unable to retrieve sessions from the client agent.
+rdp-warning:
+ title: Continue in your RDP client
+ description: Your RDP client may show a pop-up to accept a certificate to connect. This is normal for RDP clients.
+ do-not-show-again: Do not show again
diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml
index 597784975..987c0dbe3 100644
--- a/addons/core/translations/resources/en-us.yaml
+++ b/addons/core/translations/resources/en-us.yaml
@@ -709,6 +709,7 @@ target:
connect-more-info:
title: Connect for more info
description: Additional information for this target will be provided here after your session is established.
+ cert-warning: You may need to accept a client certificate when connecting to this target. Your RDP client should open a pop-up window if you do.
titles:
new: New Target
active-sessions: Active sessions
diff --git a/addons/rose/addon/helpers/map-type-to-color.js b/addons/rose/addon/helpers/map-type-to-color.js
deleted file mode 100644
index dc8a0cdc7..000000000
--- a/addons/rose/addon/helpers/map-type-to-color.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Copyright (c) HashiCorp, Inc.
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-export default function mapTypeToColor(flashMsgType) {
- // Maps the flash message type parameter to the correct hds color.
- switch (flashMsgType) {
- case 'error':
- return 'critical';
- case 'success':
- return 'success';
- case 'warning':
- return 'warning';
- default:
- return 'neutral';
- }
-}
diff --git a/addons/rose/app/helpers/map-type-to-color.js b/addons/rose/app/helpers/map-type-to-color.js
deleted file mode 100644
index b06c6c474..000000000
--- a/addons/rose/app/helpers/map-type-to-color.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * Copyright (c) HashiCorp, Inc.
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-export { default } from 'rose/helpers/map-type-to-color';
diff --git a/addons/rose/tests/integration/helpers/map-type-to-color-test.js b/addons/rose/tests/integration/helpers/map-type-to-color-test.js
deleted file mode 100644
index 14071b581..000000000
--- a/addons/rose/tests/integration/helpers/map-type-to-color-test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * Copyright (c) HashiCorp, Inc.
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'ember-qunit';
-import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
-
-module('Integration | Helper | map-type-to-color', function (hooks) {
- setupRenderingTest(hooks);
-
- test('it returns correct color when flash message type is `success`', async function (assert) {
- this.set('inputValue', 'success');
-
- await render(hbs`{{map-type-to-color this.inputValue}}`);
-
- assert.dom(this.element).hasText('success');
- });
-
- test('it returns correct color when flash message type is `error`', async function (assert) {
- this.set('inputValue', 'error');
-
- await render(hbs`{{map-type-to-color this.inputValue}}`);
-
- assert.dom(this.element).hasText('critical');
- });
-
- test('it returns correct color when flash message type is `warning`', async function (assert) {
- this.set('inputValue', 'warning');
-
- await render(hbs`{{map-type-to-color this.inputValue}}`);
-
- assert.dom(this.element).hasText('warning');
- });
-
- test('it returns default color when flash message type does not match', async function (assert) {
- this.set('inputValue', 'other');
-
- await render(hbs`{{map-type-to-color this.inputValue}}`);
-
- assert.dom(this.element).hasText('neutral');
- });
-});
diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js
index 0334df8ea..1f21cd03f 100644
--- a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js
+++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js
@@ -56,7 +56,8 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
}
} else {
this.flashMessages.danger(this.intl.t('errors.404.title'), {
- notificationType: 'error',
+ color: 'critical',
+ title: this.intl.t('states.error'),
sticky: true,
dismiss: (flash) => flash.destroyMessage(),
});
diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js
index cd2a500d7..e0e6d7297 100644
--- a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js
+++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js
@@ -10,6 +10,7 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
// =services
@service can;
@service flashMessages;
+ @service intl;
// =methods
/**
@@ -28,7 +29,8 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
// Alert user of error occurred during download.
const error = e.errors[0];
this.flashMessages.danger(error.detail, {
- notificationType: 'error',
+ color: 'critical',
+ title: this.intl.t('states.error'),
sticky: true,
dismiss: (flash) => flash.destroyMessage(),
});
diff --git a/ui/admin/app/templates/application.hbs b/ui/admin/app/templates/application.hbs
index 8cacfca02..ca787e70d 100644
--- a/ui/admin/app/templates/application.hbs
+++ b/ui/admin/app/templates/application.hbs
@@ -137,12 +137,12 @@
{{#each this.flashMessages.queue as |flash|}}
- {{t (concat 'states.' flash.notificationType)}}
+ {{flash.title}}
{{flash.message}}
diff --git a/ui/desktop/app/routes/scopes/scope/projects.js b/ui/desktop/app/routes/scopes/scope/projects.js
index 8aeaadc50..d8bd6ed49 100644
--- a/ui/desktop/app/routes/scopes/scope/projects.js
+++ b/ui/desktop/app/routes/scopes/scope/projects.js
@@ -114,7 +114,8 @@ export default class ScopesScopeProjectsRoute extends Route {
this.flashMessages.danger(
this.intl.t('errors.client-agent-failed.sessions'),
{
- notificationType: 'error',
+ color: 'critical',
+ title: this.intl.t('states.error'),
sticky: true,
dismiss: (flash) => flash.destroyMessage(),
},
diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions/session.js b/ui/desktop/app/routes/scopes/scope/projects/sessions/session.js
index e1fdce0cb..325b4709d 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/sessions/session.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/sessions/session.js
@@ -5,7 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
-
+import { TYPE_TARGET_RDP } from 'api/models/target';
const { __electronLog } = globalThis;
export default class ScopesScopeProjectsSessionsSessionRoute extends Route {
@@ -16,6 +16,7 @@ export default class ScopesScopeProjectsSessionsSessionRoute extends Route {
@service clientAgentSessions;
@service flashMessages;
@service intl;
+ @service storage;
// =methods
@@ -27,6 +28,30 @@ export default class ScopesScopeProjectsSessionsSessionRoute extends Route {
*/
async model({ session_id }) {
const session = await this.store.findRecord('session', session_id);
+ // If it's an RDP session, we show a warning message if the user hasn't dismissed it before.
+ // This is to inform users about the potential security risks associated with RDP sessions.
+ // We store a flag in localStorage to track whether the user has dismissed this warning.
+ if (
+ session.target?.type === TYPE_TARGET_RDP &&
+ !this.storage.getItem('doNotShowRdpWarningAgain')
+ ) {
+ this.flashMessages.warning(
+ this.intl.t('errors.rdp-warning.description'),
+ {
+ color: 'neutral',
+ title: this.intl.t('errors.rdp-warning.title'),
+ sticky: true,
+ dismiss: (flash) => flash.destroyMessage(),
+ button: {
+ label: this.intl.t('errors.rdp-warning.do-not-show-again'),
+ action: (flash) => {
+ this.storage.setItem('doNotShowRdpWarningAgain', 'true');
+ flash.destroyMessage();
+ },
+ },
+ },
+ );
+ }
// If we don't have any credentials, we'll try to fetch them from the client agent in case this session
// was initiated through the client agent.
@@ -49,7 +74,8 @@ export default class ScopesScopeProjectsSessionsSessionRoute extends Route {
this.flashMessages.danger(
this.intl.t('errors.client-agent-failed.sessions'),
{
- notificationType: 'error',
+ color: 'critical',
+ title: this.intl.t('states.error'),
sticky: true,
dismiss: (flash) => flash.destroyMessage(),
},
diff --git a/ui/desktop/app/templates/application.hbs b/ui/desktop/app/templates/application.hbs
index 549df8352..193cc65ee 100644
--- a/ui/desktop/app/templates/application.hbs
+++ b/ui/desktop/app/templates/application.hbs
@@ -86,13 +86,21 @@
{{#each this.flashMessages.queue as |flash|}}
- {{t (concat 'states.' flash.notificationType)}}
+ {{flash.title}}
{{flash.message}}
+
+ {{#if flash.button}}
+
+ {{/if}}
{{/each}}
diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
index 238f38ed6..01d9845f0 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target/index.hbs
@@ -83,6 +83,11 @@
{{/if}}
{{else}}
+
+ {{t
+ 'resources.target.messages.connect-more-info.cert-warning'
+ }}
+
{
+ flash.destroyMessage();
+ },
+ },
+ });
+
+ await visit(urls.clusterUrl);
+
+ assert
+ .dom(TOAST_DO_NOT_SHOW_AGAIN_BUTTON)
+ .hasText('Do Not Show Again', 'The button has the correct label.');
+ assert
+ .dom(TOAST)
+ .containsText('Warning', 'The toast has the correct title.');
+ // check classname for color
+ assert
+ .dom(TOAST)
+ .hasClass(
+ 'hds-alert--color-critical',
+ 'The toast has the correct color class.',
+ );
+
+ await click(TOAST_DO_NOT_SHOW_AGAIN_BUTTON);
+
+ assert
+ .dom(TOAST)
+ .doesNotExist('The toast is dismissed after clicking the button.');
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
index 39475ec4c..228c22e55 100644
--- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
@@ -22,6 +22,10 @@ module('Acceptance | projects | sessions | session', function (hooks) {
setupStubs(hooks);
const TARGET_CONNECT_BUTTON = '[data-test-target-detail-connect-button]';
+ const TOAST = '[data-test-toast-notification]';
+ const TOAST_DO_NOT_SHOW_AGAIN_BUTTON =
+ '[data-test-toast-notification] button';
+ const TOAST_DISMISS_BUTTON = '[aria-label="Dismiss"]';
const instances = {
scopes: {
@@ -44,8 +48,10 @@ module('Acceptance | projects | sessions | session', function (hooks) {
projects: null,
targets: null,
target: null,
+ rdpTarget: null,
sessions: null,
session: null,
+ rdpSession: null,
};
const setDefaultClusterUrl = (test) => {
@@ -94,6 +100,16 @@ module('Acceptance | projects | sessions | session', function (hooks) {
{ scope: instances.scopes.project, address: 'localhost' },
'withAssociations',
);
+ instances.rdpTarget = this.server.create(
+ 'target',
+ {
+ scope: instances.scopes.project,
+ address: 'rdp.example.com',
+ type: 'rdp',
+ },
+ 'withAssociations',
+ );
+
instances.session = this.server.create(
'session',
{
@@ -105,14 +121,29 @@ module('Acceptance | projects | sessions | session', function (hooks) {
'withAssociations',
);
+ instances.rdpSession = this.server.create(
+ 'session',
+ {
+ scope: instances.scopes.project,
+ status: STATUS_SESSION_ACTIVE,
+ user: instances.user,
+ },
+ 'withAssociations',
+ );
+
+ instances.rdpSession.update({
+ target: instances.rdpTarget,
+ });
+
urls.scopes.global = `/scopes/${instances.scopes.global.id}`;
urls.scopes.org = `/scopes/${instances.scopes.org.id}`;
urls.projects = `${urls.scopes.org}/projects`;
urls.targets = `${urls.projects}/targets`;
urls.target = `${urls.targets}/${instances.target.id}`;
+ urls.rdpTarget = `${urls.targets}/${instances.rdpTarget.id}`;
urls.sessions = `${urls.projects}/sessions`;
urls.session = `${urls.projects}/sessions/${instances.session.id}`;
-
+ urls.rdpSession = `${urls.projects}/sessions/${instances.rdpSession.id}`;
// Mock the postMessage interface used by IPC.
this.owner.register('service:browser/window', WindowMockIPC);
setDefaultClusterUrl(this);
@@ -239,6 +270,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
.dom('.secret-container:nth-of-type(2)')
.includesText('email.address');
await click('.secret-container:nth-of-type(2) .hds-icon');
+
assert
.dom('.secret-container:nth-of-type(2) .secret-content')
.hasText('test.com');
@@ -302,6 +334,71 @@ module('Acceptance | projects | sessions | session', function (hooks) {
.hasText(expectedOutput);
});
+ test('visiting an RDP session should display a toast notification', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ host_id: 'h_123',
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+
+ await visit(urls.rdpTarget);
+
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.strictEqual(currentURL(), urls.rdpSession);
+
+ // check if the toast notification is visible
+ assert.dom(TOAST).isVisible();
+ assert.dom(TOAST_DISMISS_BUTTON).isVisible();
+ assert.dom(TOAST_DO_NOT_SHOW_AGAIN_BUTTON).hasText('Do not show again');
+
+ // Click the dismiss button to close the toast
+ await click(TOAST_DISMISS_BUTTON);
+
+ assert.dom(TOAST).doesNotExist();
+ });
+
+ test('clicking on `do not show again` button prevents the toast warning from showing again', async function (assert) {
+ // First RDP Session visit should show the toast notification
+ this.ipcStub.withArgs('cliExists').returns(true);
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ protocol: 'rdp',
+ });
+
+ await visit(urls.rdpTarget);
+
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.dom(TOAST).isVisible();
+
+ // Click the "Do not show again" button
+ await click(TOAST_DO_NOT_SHOW_AGAIN_BUTTON);
+ assert.dom(TOAST).doesNotExist();
+
+ // check that the localStorage item is set
+ assert.ok(
+ this.owner.lookup('service:storage').getItem('doNotShowRdpWarningAgain'),
+ );
+
+ // Second visit to the same RDP session should not show the toast again
+ await visit(urls.rdpTarget);
+
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.dom(TOAST).doesNotExist();
+ // Cleanup localStorage for other tests
+ this.owner.lookup('service:storage').removeItem('doNotShowRdpWarningAgain');
+
+ // Verify that the localStorage item is removed
+ assert.notOk(
+ this.owner.lookup('service:storage').getItem('doNotShowRdpWarningAgain'),
+ );
+ });
+
test('visiting a session that does not have permissions to read a host', async function (assert) {
setRunOptions({
rules: {
@@ -324,6 +421,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
});
await visit(urls.target);
+
await click(TARGET_CONNECT_BUTTON);
assert.strictEqual(currentURL(), urls.session);
@@ -357,7 +455,9 @@ module('Acceptance | projects | sessions | session', function (hooks) {
});
await visit(urls.target);
+
await click(TARGET_CONNECT_BUTTON);
+
assert.strictEqual(currentURL(), urls.session);
});
@@ -410,6 +510,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.expect(1);
await visit(urls.session);
+
await click('[data-test-session-detail-cancel-button]');
assert
@@ -430,6 +531,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
assert.expect(1);
await visit(urls.session);
+
await click('[data-test-session-detail-cancel-button]');
assert.strictEqual(currentURL(), urls.targets);
@@ -449,6 +551,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
this.server.post('/sessions/:id_method', () => new Response(400));
await visit(urls.session);
+
await click('[data-test-session-detail-cancel-button]');
assert
@@ -470,6 +573,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
this.ipcStub.withArgs('stop').throws();
await visit(urls.session);
+
await click('[data-test-session-detail-cancel-button]');
assert