feat: 🎸 display toast notifications for rdp sessions
This commit is contained in:
1 parent
4a179ac3e3
commit
0a75e05b28
15 files changed
+240
-83
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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(),
|
||||
});
|
||||
|
||||
+3
-1
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user