From 0a75e05b2836d1064679c3bb4507fedd2001b868 Mon Sep 17 00:00:00 2001 From: Dharini Date: Mon, 14 Jul 2025 13:15:58 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20display=20toast=20notifi?= =?UTF-8?q?cations=20for=20rdp=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/core/addon/decorators/notify.js | 6 +- addons/core/translations/errors/en-us.yaml | 4 + addons/core/translations/resources/en-us.yaml | 1 + .../rose/addon/helpers/map-type-to-color.js | 18 --- addons/rose/app/helpers/map-type-to-color.js | 6 - .../helpers/map-type-to-color-test.js | 45 -------- .../channels-by-connection/channel.js | 3 +- .../channels-by-connection/channel/index.js | 4 +- ui/admin/app/templates/application.hbs | 4 +- .../app/routes/scopes/scope/projects.js | 3 +- .../scopes/scope/projects/sessions/session.js | 30 ++++- ui/desktop/app/templates/application.hbs | 12 +- .../scope/projects/targets/target/index.hbs | 5 + .../tests/acceptance/application-test.js | 76 ++++++++++++- .../projects/sessions/session-test.js | 106 +++++++++++++++++- 15 files changed, 240 insertions(+), 83 deletions(-) delete mode 100644 addons/rose/addon/helpers/map-type-to-color.js delete mode 100644 addons/rose/app/helpers/map-type-to-color.js delete mode 100644 addons/rose/tests/integration/helpers/map-type-to-color-test.js 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