feat: 🎸 display toast notifications for rdp sessions

This commit is contained in:
Dharini committed 2025-09-03 16:04:17 -07:00
1 parent 4a179ac3e3
commit 0a75e05b28
15 files changed
+240 -83

No files matched your search

+4 -2
View File
@@ -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(),
});
@@ -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
@@ -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
@@ -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';
}
}
@@ -1,6 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'rose/helpers/map-type-to-color';
@@ -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');
});
});
@@ -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(),
});
@@ -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(),
});
+2 -2
View File
@@ -137,12 +137,12 @@
{{#each this.flashMessages.queue as |flash|}}
<div class='ember-notify-show ember-notify'>
<Hds::Toast
@color={{map-type-to-color flash.notificationType}}
@color={{flash.color}}
@onDismiss={{fn flash.dismiss flash}}
data-test-toast-notification
as |T|
>
<T.Title>{{t (concat 'states.' flash.notificationType)}}</T.Title>
<T.Title>{{flash.title}}</T.Title>
<T.Description>{{flash.message}}</T.Description>
</Hds::Toast>
</div>
@@ -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(),
},
@@ -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(),
},
+10 -2
View File
@@ -86,13 +86,21 @@
{{#each this.flashMessages.queue as |flash|}}
<div class='ember-notify-show ember-notify'>
<Hds::Toast
@color={{map-type-to-color flash.notificationType}}
@color={{flash.color}}
@onDismiss={{fn flash.dismiss flash}}
data-test-toast-notification
as |T|
>
<T.Title>{{t (concat 'states.' flash.notificationType)}}</T.Title>
<T.Title>{{flash.title}}</T.Title>
<T.Description>{{flash.message}}</T.Description>
{{#if flash.button}}
<T.Button
@color='secondary'
@text={{flash.button.label}}
{{on 'click' (fn flash.button.action flash)}}
/>
{{/if}}
</Hds::Toast>
</div>
{{/each}}
@@ -83,6 +83,11 @@
</Hds::ApplicationState>
{{/if}}
{{else}}
<Hds::Alert @type='compact' as |A|>
<A.Description>{{t
'resources.target.messages.connect-more-info.cert-warning'
}}</A.Description>
</Hds::Alert>
<Hds::ApplicationState as |A|>
<A.Header
@title={{t 'resources.target.messages.connect-more-info.title'}}
@@ -4,18 +4,23 @@
*/
import { module, test } from 'qunit';
import { visit, find } from '@ember/test-helpers';
import { visit, find, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import sinon from 'sinon';
import { invalidateSession } from 'ember-simple-auth/test-support';
import { setupBrowserFakes } from 'ember-browser-services/test-support';
module('Acceptance | index', function (hooks) {
module('Acceptance | Application', function (hooks) {
setupApplicationTest(hooks);
setupBrowserFakes(hooks, { window: true });
setupMirage(hooks);
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 stubs = {
ipcService: null,
};
@@ -74,4 +79,71 @@ module('Acceptance | index', function (hooks) {
'Does not add header padding around native window actions',
);
});
test('it renders a simple flash message', async function (assert) {
assert.expect(5);
const flashMessages = this.owner.lookup('service:flash-messages');
flashMessages.success('This is a success message.', {
color: 'success',
title: 'success',
});
await visit(urls.clusterUrl);
assert.dom(TOAST).exists('The toast notification is rendered.');
assert
.dom(TOAST)
.containsText('success', 'The toast has the correct title.');
assert
.dom(TOAST)
.hasClass(
'hds-alert--color-success',
'The toast has the correct color class.',
);
assert
.dom(TOAST)
.containsText(
'This is a success message.',
'The toast has the correct description.',
);
assert.dom(TOAST_DISMISS_BUTTON).isVisible('The toast has a close button.');
});
test('it renders a flash message with a custom button and handles the action', async function (assert) {
assert.expect(4);
const flashMessages = this.owner.lookup('service:flash-messages');
flashMessages.warning('This is a warning.', {
color: 'critical',
title: 'Warning',
button: {
label: 'Do Not Show Again',
action: (flash) => {
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.');
});
});
@@ -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