diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js
index b376de313..5b34bc5b7 100644
--- a/addons/api/mirage/scenarios/ipc.js
+++ b/addons/api/mirage/scenarios/ipc.js
@@ -246,6 +246,8 @@ export default function initializeMockIPC(server, config) {
}
pauseClientAgent() {}
resumeClientAgent() {}
+ hasRunningSessions() {}
+ stopAll() {}
}
/**
diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml
index deee3e030..554d81b9c 100644
--- a/addons/core/translations/resources/en-us.yaml
+++ b/addons/core/translations/resources/en-us.yaml
@@ -343,6 +343,12 @@ session:
connected:
title: Connected
description: You can now access {targetDisplayName}
+ stop-sessions-and-signout:
+ title: Sign out of Boundary?
+ description: Signing out will stop all active and pending target sessions. Are you sure you want to sign out?
+ stop-sessions-before-quit:
+ title: Close sessions before quitting?
+ description: Quitting the app will stop all active and pending sessions. Are you sure you want to quit?
actions:
connect: Connect
end: End session
diff --git a/e2e-tests/desktop/tests/scope.spec.js b/e2e-tests/desktop/tests/scope.spec.js
index fad0beda5..55e2b0afe 100644
--- a/e2e-tests/desktop/tests/scope.spec.js
+++ b/e2e-tests/desktop/tests/scope.spec.js
@@ -54,7 +54,7 @@ test.describe('Scope tests', () => {
await expect(headerNavLocator).toBeVisible();
await expect(
headerNavLocator.locator('.hds-dropdown-toggle-button'),
- ).toHaveText('Global');
+ ).toHaveText('Global', { ignoreCase: true });
await expect(
authedPage.getByRole('link', { name: targetA.name }),
diff --git a/ui/desktop/app/components/session/tabs/index.js b/ui/desktop/app/components/session/tabs/index.js
index 589ffe4ac..de5575f93 100644
--- a/ui/desktop/app/components/session/tabs/index.js
+++ b/ui/desktop/app/components/session/tabs/index.js
@@ -39,7 +39,7 @@ export default class SessionTerminalTabsComponent extends Component {
id;
terminal;
- removeListener;
+ removeTerminalListener;
// =actions
@@ -111,9 +111,7 @@ export default class SessionTerminalTabsComponent extends Component {
}
window.terminal?.remove(this.id);
window.onresize = null;
- if (this.removeListener) {
- this.removeListener();
- }
+ this.removeTerminalListener?.();
}
#setupTerminal(fitAddon, xterm, termContainer) {
@@ -122,7 +120,7 @@ export default class SessionTerminalTabsComponent extends Component {
xterm.onData((data) => window.terminal.send(data, this.id));
// Save the handler to cleanup the listener on the renderer process later
- this.removeListener = window.terminal.receive((event, value) => {
+ this.removeTerminalListener = window.terminal.receive((value) => {
xterm.write(value);
}, this.id);
diff --git a/ui/desktop/app/components/settings-card/user/index.hbs b/ui/desktop/app/components/settings-card/user/index.hbs
index 3e8fefff7..f36f1bbe6 100644
--- a/ui/desktop/app/components/settings-card/user/index.hbs
+++ b/ui/desktop/app/components/settings-card/user/index.hbs
@@ -36,12 +36,12 @@
<:action>
\ No newline at end of file
diff --git a/ui/desktop/app/components/settings-card/user/index.js b/ui/desktop/app/components/settings-card/user/index.js
index c341dc245..e47a4818c 100644
--- a/ui/desktop/app/components/settings-card/user/index.js
+++ b/ui/desktop/app/components/settings-card/user/index.js
@@ -5,7 +5,6 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
-import { action } from '@ember/object';
export default class SettingsUserInfoComponent extends Component {
// =services
@@ -49,14 +48,4 @@ export default class SettingsUserInfoComponent extends Component {
const formattedAuthenticatorType = authenticator.split(':')[1];
return this.intl.t(`resources.account.types.${formattedAuthenticatorType}`);
}
-
- // =actions
-
- /**
- * Delegates invalidation to the session service.
- */
- @action
- invalidateSession() {
- this.session.invalidate();
- }
}
diff --git a/ui/desktop/app/controllers/application.js b/ui/desktop/app/controllers/application.js
index 34042ec39..dab1c3eba 100644
--- a/ui/desktop/app/controllers/application.js
+++ b/ui/desktop/app/controllers/application.js
@@ -7,22 +7,63 @@ import Controller from '@ember/controller';
import { service } from '@ember/service';
import { getOwner } from '@ember/application';
import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
export default class ApplicationController extends Controller {
// =services
- @service ipc;
- @service session;
@service clusterUrl;
@service flashMessages;
+ @service ipc;
+ @service session;
+ @service('browser/window') window;
+
+ @tracked isLoggingOut = false;
+ @tracked isAppQuitting = false;
+
+ // =attributes
+ removeOnAppQuitListener;
+
+ constructor() {
+ super(...arguments);
+ // Listen for when user attempts to quit app
+ // Setup removeOnAppQuitListener to destroy the listener afterwards
+ this.removeOnAppQuitListener = this.window.electron?.onAppQuit(() => {
+ this.isAppQuitting = true;
+ });
+ }
// =actions
/**
- * Delegates invalidation to the session service.
+ * Stop all active/pending target sessions
+ * Logout or close app
*/
@action
- invalidateSession() {
- this.session.invalidate();
+ async confirmCloseSessions() {
+ await this.ipc.invoke('stopAll');
+ if (this.isAppQuitting) {
+ this.isAppQuitting = false;
+ this.close();
+ } else {
+ // this.session.invalidate() comes from Ember Simple Auth BaseSessionService
+ this.session.invalidate();
+ }
+
+ this.isLoggingOut = false;
+ }
+
+ /**
+ * Only renders the signout modal if target sessions are running
+ */
+ @action
+ async showModalOrLogout() {
+ const hasRunningSessions = await this.ipc.invoke('hasRunningSessions');
+ if (hasRunningSessions) {
+ this.isLoggingOut = true;
+ } else {
+ this.session.invalidate();
+ }
}
/**
@@ -32,7 +73,7 @@ export default class ApplicationController extends Controller {
@action
disconnect() {
this.clusterUrl.resetClusterUrl();
- this.invalidateSession();
+ this.session.invalidate();
}
@action
@@ -75,4 +116,15 @@ export default class ApplicationController extends Controller {
rootEl.classList.remove('rose-theme-light');
}
}
+
+ @action
+ cancel() {
+ this.isLoggingOut = false;
+ this.isAppQuitting = false;
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.removeOnAppQuitListener?.();
+ }
}
diff --git a/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js b/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js
index f6502c0e1..c48e6f7ce 100644
--- a/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js
+++ b/ui/desktop/app/controllers/scopes/scope/projects/settings/index.js
@@ -9,7 +9,8 @@ import { action } from '@ember/object';
export default class ScopesScopeProjectsSettingsIndexController extends Controller {
@controller('application') application;
- //actions
+ // =actions
+
/**
* Calls the Application controller's toggleTheme method
* @param {string} theme - "light", "dark", or nullish (system default)
diff --git a/ui/desktop/app/templates/application.hbs b/ui/desktop/app/templates/application.hbs
index 29b525a90..549df8352 100644
--- a/ui/desktop/app/templates/application.hbs
+++ b/ui/desktop/app/templates/application.hbs
@@ -63,8 +63,8 @@
/>
{{/if}}
{{t 'actions.deauthenticate'}}
@@ -155,4 +155,40 @@
{{/if}}
-
\ No newline at end of file
+
+
+{{#if (or this.isLoggingOut this.isAppQuitting)}}
+
+ {{#if this.isAppQuitting}}
+
+ {{t 'resources.session.messages.stop-sessions-before-quit.title'}}
+
+
+ {{t 'resources.session.messages.stop-sessions-before-quit.description'}}
+
+ {{else}}
+ {{! If not rendering the isAppQuitting modal, render isLoggingOut modal }}
+
+ {{t 'resources.session.messages.stop-sessions-and-signout.title'}}
+
+
+ {{t 'resources.session.messages.stop-sessions-and-signout.description'}}
+
+ {{/if}}
+
+
+
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs
index 5220d737d..53b9c073b 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs
@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
-
+
diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js
index 7c0b939c5..28eea08af 100644
--- a/ui/desktop/electron-app/src/index.js
+++ b/ui/desktop/electron-app/src/index.js
@@ -13,7 +13,6 @@ const {
const {
session,
app,
- dialog,
protocol,
BrowserWindow,
Menu,
@@ -22,7 +21,6 @@ const {
} = require('electron');
require('./ipc/handlers.js');
const log = require('electron-log/main');
-const fs = require('fs');
const { generateCSPHeader } = require('./config/content-security-policy.js');
const runtimeSettings = require('./services/runtime-settings.js');
@@ -40,6 +38,7 @@ const { version } = require('./cli/index.js');
const isDev = require('electron-is-dev');
// Register the custom file protocol
+// This will render as the default cluster URL: serve://boundary
const emberAppProtocol = 'serve';
const emberAppName = 'boundary';
const emberAppURL = `${emberAppProtocol}://${emberAppName}`;
@@ -129,7 +128,7 @@ const createWindow = async (partition, closeWindowCB) => {
const browserWindow = new BrowserWindow(browserWindowOptions);
// If the user-specified cluster URL changes, reload the page so that
- // the CSP can be refreshed with the this source allowed
+ // the content security policy (CSP) can be refreshed with the source allowed
runtimeSettings.onClusterUrlChange(
async () => await browserWindow.loadURL(emberAppURL),
);
@@ -143,6 +142,8 @@ const createWindow = async (partition, closeWindowCB) => {
await browserWindow.loadURL(emberAppURL);
});
+ // Emitted when the renderer process unexpectedly disappears.
+ // This is normally because it was crashed or killed.
browserWindow.webContents.on('render-process-gone', () => {
console.log(
'Your Ember app (or other code) in the main window has crashed.',
@@ -160,7 +161,7 @@ const createWindow = async (partition, closeWindowCB) => {
});
// Opens external links in the host default browser.
- // We allow developer.hashicorp.com domain to open on external window
+ // We allow developer.hashicorp.com domain to open on an external window
// and releases.hashicorp.com domain to download the desktop app or
// link to the release page for the desktop app.
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
@@ -204,15 +205,15 @@ app.on('window-all-closed', () => {
app.on('ready', async () => {
// Setup a partition
- // Must be prefixed with persist: in order to save things like localStorage
+ // Must be prefixed with "persist:" in order to save things like localStorage
// data. Without this, the session is in-memory only.
// https://www.electronjs.org/docs/api/session#sessionfrompartitionpartition-options
const partition = `persist:${emberAppName}`;
const ses = session.fromPartition(partition);
// Register custom protocol
- // This file protocol is the exclusive protocol for this application. All
- // other protocols are disabled.
+ // This file protocol is the exclusive protocol for this application.
+ // All other protocols are disabled.
ses.protocol.handle(emberAppProtocol, (request) => {
/* eng-disable PROTOCOL_HANDLER_JS_CHECK */
const isDir = request.url.endsWith('/');
@@ -270,8 +271,8 @@ app.on('ready', async () => {
/**
* Need for Mac OS behavior of closing the window but not the app.
- * This allows to reopen the window if the user clicks Boundary icon in the dock
- * while the app is not close.
+ * This allows a user to reopen the window when clicking the Boundary icon in the dock,
+ * only when the app has not been closed.
* More info: https://www.electronjs.org/docs/latest/tutorial/quick-start#open-a-window-if-none-are-open-macos
*/
app.on('activate', async () => {
@@ -284,18 +285,13 @@ app.on('ready', async () => {
});
/**
- * Prompt for closing spawned processes
+ * Prompt user to confirm action before closing spawned processes
+ * This interaction lives in the ember app using Hds::Modal
*/
-app.on('before-quit', (event) => {
+app.on('before-quit', async (event) => {
if (sessionManager.hasRunningSessions) {
- const dialogOpts = {
- type: 'question',
- buttons: ['Close', 'Cancel'],
- detail: 'Close sessions before quitting?',
- };
-
- const buttonId = dialog.showMessageBoxSync(null, dialogOpts);
- buttonId === 0 ? sessionManager.stopAll() : event.preventDefault();
+ mainWindow.webContents.send('onAppQuit');
+ event.preventDefault();
}
});
@@ -312,7 +308,7 @@ app.on('quit', () => {
// from the exception can cause additional unforeseen and unpredictable issues.
//
// Attempting to resume normally after an uncaught exception can be similar to pulling out
-// of the power cord when upgrading a computer -- nine out of ten times nothing happens -
+// the power cord when upgrading a computer -- nine out of ten times nothing happens -
// but the 10th time, the system becomes corrupted.
//
// The correct use of 'uncaughtException' is to perform synchronous cleanup of allocated
diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js
index 2807be586..c8f203a23 100644
--- a/ui/desktop/electron-app/src/ipc/handlers.js
+++ b/ui/desktop/electron-app/src/ipc/handlers.js
@@ -76,10 +76,15 @@ handle('connect', ({ target_id, token, host_id }) =>
);
/**
- * Cancel an established boundary session spawned process.
+ * Stop an established boundary session spawned process.
*/
handle('stop', ({ session_id }) => sessionManager.stopById(session_id));
+/**
+ * Stop all active and pending target sessions.
+ */
+handle('stopAll', async () => sessionManager.stopAll());
+
/**
* Check for OS window chrome. Enabled on MacOS only.
* Window chrome is disabled regardless of platform for
@@ -116,6 +121,12 @@ handle('toggleFullscreenWindow', () => {
*/
handle('closeWindow', () => app.quit());
+/**
+ * Check if session manager has running sessions
+ * Return boolean
+ */
+handle('hasRunningSessions', () => sessionManager.hasRunningSessions);
+
/**
* Focus the window
*/
diff --git a/ui/desktop/electron-app/src/models/session-manager.js b/ui/desktop/electron-app/src/models/session-manager.js
index b56a4284e..20399470d 100644
--- a/ui/desktop/electron-app/src/models/session-manager.js
+++ b/ui/desktop/electron-app/src/models/session-manager.js
@@ -39,8 +39,13 @@ class SessionManager {
return session?.stop?.();
}
+ /**
+ * Stop all active and pending target sessions
+ * Returning Promise.all() ensures all sessions in the list have been
+ * stopped before calling the next fn
+ */
stopAll() {
- this.#sessions.forEach((session) => session.stop());
+ return Promise.all(this.#sessions.map((session) => session.stop()));
}
}
diff --git a/ui/desktop/electron-app/src/preload.js b/ui/desktop/electron-app/src/preload.js
index 04db76453..c87707144 100644
--- a/ui/desktop/electron-app/src/preload.js
+++ b/ui/desktop/electron-app/src/preload.js
@@ -22,11 +22,12 @@ contextBridge.exposeInMainWorld('terminal', {
},
receive: (callback, id) => {
const incomingDataChannel = `terminalIncomingData-${id}`;
- ipcRenderer.on(incomingDataChannel, callback);
+ const listenerCallback = (_event, value) => callback(value);
+ ipcRenderer.on(incomingDataChannel, listenerCallback);
// Return a function for the caller to handle cleaning up the listener
return () => {
- return ipcRenderer.removeListener(incomingDataChannel, callback);
+ return ipcRenderer.removeListener(incomingDataChannel, listenerCallback);
};
},
create: (vars) => {
@@ -61,3 +62,18 @@ process.once('loaded', () => {
}
});
});
+
+/**
+ * Listener on electron app when user triggers before-quit event
+ */
+contextBridge.exposeInMainWorld('electron', {
+ onAppQuit: (callback) => {
+ // Don't pass in callback directly so users can't access passed in event for security
+ const listenerCallback = () => callback();
+ ipcRenderer.on('onAppQuit', listenerCallback);
+
+ return () => {
+ return ipcRenderer.removeListener('onAppQuit', listenerCallback);
+ };
+ },
+});
diff --git a/ui/desktop/tests/acceptance/authentication-test.js b/ui/desktop/tests/acceptance/authentication-test.js
index fbc5e0a44..afd2bb75f 100644
--- a/ui/desktop/tests/acceptance/authentication-test.js
+++ b/ui/desktop/tests/acceptance/authentication-test.js
@@ -4,7 +4,13 @@
*/
import { module, test } from 'qunit';
-import { visit, currentURL, fillIn, click, find } from '@ember/test-helpers';
+import {
+ visit,
+ currentURL,
+ fillIn,
+ click,
+ triggerEvent,
+} from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { Response } from 'miragejs';
@@ -15,6 +21,8 @@ import {
invalidateSession,
} from 'ember-simple-auth/test-support';
import WindowMockIPC from '../helpers/window-mock-ipc';
+import Service from '@ember/service';
+import sinon from 'sinon';
module('Acceptance | authentication', function (hooks) {
setupApplicationTest(hooks);
@@ -58,6 +66,13 @@ module('Acceptance | authentication', function (hooks) {
sessions: null,
};
+ const SIGNOUT_BTN = '[data-test-nav-signout-btn]';
+ const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]';
+ const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary';
+ const MODAL_CANCEL_BTN = '.hds-modal__footer .hds-button--color-secondary';
+ const HEADER_DROPDOWN_BTN =
+ '.rose-header-utilities .header-dropdown-button-override button';
+
const setDefaultClusterUrl = (test) => {
const windowOrigin = window.location.origin;
const clusterUrl = test.owner.lookup('service:clusterUrl');
@@ -71,6 +86,9 @@ module('Acceptance | authentication', function (hooks) {
await invalidateSession();
+ const ipcService = this.owner.lookup('service:ipc');
+ this.ipcStub = sinon.stub(ipcService, 'invoke');
+
// create scopes
instances.scopes.global = this.server.create('scope', { id: 'global' });
stubs.global = { id: 'global', type: 'global' };
@@ -128,6 +146,10 @@ module('Acceptance | authentication', function (hooks) {
setDefaultClusterUrl(this);
});
+ hooks.afterEach(async function () {
+ sinon.restore();
+ });
+
test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) {
assert.expect(2);
await visit(urls.index);
@@ -183,16 +205,11 @@ module('Acceptance | authentication', function (hooks) {
assert.ok(currentSession().isAuthenticated);
- await click(
- '.rose-header-utilities .header-dropdown-button-override button',
- );
+ await click(HEADER_DROPDOWN_BTN);
- assert.strictEqual(
- find('[data-test-sign-out]').textContent.trim(),
- 'Sign Out',
- );
+ assert.dom(SIGNOUT_BTN).includesText('Sign Out');
- await click('[data-test-sign-out]');
+ await click(SIGNOUT_BTN);
assert.notOk(currentSession().isAuthenticated);
});
@@ -235,4 +252,95 @@ module('Acceptance | authentication', function (hooks) {
assert.dom('[data-test-no-auth-methods]').includesText('No Auth Methods');
assert.dom('.change-origin').exists();
});
+
+ test('signing out with running sessions renders signout modal', async function (assert) {
+ this.ipcStub.withArgs('hasRunningSessions').returns(true);
+
+ await visit(urls.authenticate.methods.global);
+
+ await authenticateSession({ username: 'test' });
+
+ assert.ok(currentSession().isAuthenticated);
+
+ await click(HEADER_DROPDOWN_BTN);
+
+ await click(SIGNOUT_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isVisible();
+ assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?');
+
+ await click(MODAL_CANCEL_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible();
+ assert.ok(currentSession().isAuthenticated);
+ });
+
+ test('confirming signout via modal stops sessions and logs out user', async function (assert) {
+ const stopAllSessions = this.ipcStub.withArgs('stopAll');
+ this.ipcStub.withArgs('hasRunningSessions').returns(true);
+
+ await visit(urls.authenticate.methods.global);
+
+ await authenticateSession({ username: 'test' });
+
+ assert.ok(currentSession().isAuthenticated);
+
+ await click(HEADER_DROPDOWN_BTN);
+
+ await click(SIGNOUT_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isVisible();
+ assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?');
+
+ await click(MODAL_CONFIRM_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible();
+ assert.ok(stopAllSessions.calledOnce);
+ assert.notOk(currentSession().isAuthenticated);
+ });
+
+ test('attempting to quit app when signout modal is present triggers the close sessions modal', async function (assert) {
+ // We need to encapsulate the event listener inside a mocked window service to ensure
+ // the entire event is torn down (including the mocked window), since "window" exists
+ // globally across all tests, and we don't want tests impacting one another
+ const mockElectronEvent = class WindowElectronMock extends Service {
+ electron = {
+ onAppQuit: (callback) => {
+ window.addEventListener('onAppQuit', callback);
+ return () => {
+ window.removeEventListener('onAppQuit', callback);
+ };
+ },
+ };
+ };
+
+ this.owner.register('service:browser/window', mockElectronEvent);
+ const stopAllSessions = this.ipcStub.withArgs('stopAll');
+ const quitApp = this.ipcStub.withArgs('closeWindow');
+
+ this.ipcStub.withArgs('hasRunningSessions').returns(true);
+
+ await visit(urls.authenticate.methods.global);
+
+ await authenticateSession({ username: 'test' });
+
+ assert.ok(currentSession().isAuthenticated);
+
+ await click(HEADER_DROPDOWN_BTN);
+
+ await click(SIGNOUT_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isVisible();
+ assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?');
+
+ await triggerEvent(window, 'onAppQuit');
+
+ assert
+ .dom(MODAL_CLOSE_SESSIONS)
+ .includesText('Close sessions before quitting?');
+
+ await click(MODAL_CONFIRM_BTN);
+ assert.ok(stopAllSessions.calledOnce);
+ assert.ok(quitApp.calledOnce);
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/settings/index-test.js b/ui/desktop/tests/acceptance/projects/settings/index-test.js
index f99257e2e..6e1eeb341 100644
--- a/ui/desktop/tests/acceptance/projects/settings/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/settings/index-test.js
@@ -20,8 +20,6 @@ import {
import WindowMockIPC from '../../../helpers/window-mock-ipc';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
-const SIGNOUT_SELECTOR = '[data-test-signout-button]';
-
module('Acceptance | projects | settings | index', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -57,6 +55,10 @@ module('Acceptance | projects | settings | index', function (hooks) {
settings: null,
};
+ const SIGNOUT_BTN = '[data-test-settings-signout-btn]';
+ const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]';
+ const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary';
+
const setDefaultClusterUrl = (test) => {
const windowOrigin = window.location.origin;
const clusterUrl = test.owner.lookup('service:clusterUrl');
@@ -65,17 +67,20 @@ module('Acceptance | projects | settings | index', function (hooks) {
hooks.beforeEach(async function () {
await authenticateSession();
+
// Generate scopes
instances.scopes.global = this.server.create('scope', {
id: 'global',
name: 'Global',
});
const globalScope = { id: 'global', type: 'global' };
+
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: globalScope,
});
const orgScope = { id: instances.scopes.org.id, type: 'org' };
+
instances.scopes.project = this.server.create('scope', {
type: 'project',
scope: orgScope,
@@ -83,7 +88,6 @@ module('Acceptance | projects | settings | index', function (hooks) {
urls.scopes.org = `/scopes/${instances.scopes.org.id}`;
urls.scopes.global = `/scopes/${instances.scopes.global.id}`;
urls.projects = `${urls.scopes.org}/projects`;
-
urls.settings = `${urls.projects}/settings`;
this.owner.register('service:browser/window', WindowMockIPC);
@@ -113,16 +117,19 @@ module('Acceptance | projects | settings | index', function (hooks) {
assert.notOk(currentSession().get('data.theme'));
assert.notOk(getRootElement().classList.contains('rose-theme-light'));
assert.notOk(getRootElement().classList.contains('rose-theme-dark'));
+
// toggle light mode
await select('[name="theme"]', 'light');
assert.strictEqual(currentSession().get('data.theme'), 'light');
assert.ok(getRootElement().classList.contains('rose-theme-light'));
assert.notOk(getRootElement().classList.contains('rose-theme-dark'));
+
// toggle dark mode
await select('[name="theme"]', 'dark');
assert.strictEqual(currentSession().get('data.theme'), 'dark');
assert.notOk(getRootElement().classList.contains('rose-theme-light'));
assert.ok(getRootElement().classList.contains('rose-theme-dark'));
+
// toggle system default
await select('[name="theme"]', 'system-default-theme');
assert.strictEqual(
@@ -133,12 +140,37 @@ module('Acceptance | projects | settings | index', function (hooks) {
assert.notOk(getRootElement().classList.contains('rose-theme-dark'));
});
- test('clicking sign-out button logs out the user', async function (assert) {
- await authenticateSession({ username: 'testuser' });
+ test('clicking signout button logs out the user', async function (assert) {
assert.expect(2);
- await visit(urls.settings);
+
+ await authenticateSession({ username: 'testuser' });
assert.ok(currentSession().isAuthenticated);
- await click(SIGNOUT_SELECTOR);
+
+ await visit(urls.settings);
+
+ await click(SIGNOUT_BTN);
+
+ assert.notOk(currentSession().isAuthenticated);
+ });
+
+ test('confirming signout with running sessions stops sessions and logs out user', async function (assert) {
+ const stopAllSessions = this.ipcStub.withArgs('stopAll');
+ this.ipcStub.withArgs('hasRunningSessions').returns(true);
+
+ await authenticateSession({ username: 'testuser' });
+ assert.ok(currentSession().isAuthenticated);
+
+ await visit(urls.settings);
+
+ await click(SIGNOUT_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isVisible();
+ assert.dom(MODAL_CLOSE_SESSIONS).includesText('Sign out of Boundary?');
+
+ await click(MODAL_CONFIRM_BTN);
+
+ assert.dom(MODAL_CLOSE_SESSIONS).isNotVisible();
+ assert.ok(stopAllSessions.calledOnce);
assert.notOk(currentSession().isAuthenticated);
});
});
diff --git a/ui/desktop/tests/helpers/window-mock-ipc.js b/ui/desktop/tests/helpers/window-mock-ipc.js
index d48fef131..3e0ec24d7 100644
--- a/ui/desktop/tests/helpers/window-mock-ipc.js
+++ b/ui/desktop/tests/helpers/window-mock-ipc.js
@@ -55,6 +55,8 @@ class MockIPC {
}
pauseClientAgent() {}
resumeClientAgent() {}
+ hasRunningSessions() {}
+ stopAll() {}
}
/**
diff --git a/ui/desktop/tests/integration/components/settings-card/user-test.js b/ui/desktop/tests/integration/components/settings-card/user-test.js
index 91bd82cd4..5df7779a9 100644
--- a/ui/desktop/tests/integration/components/settings-card/user-test.js
+++ b/ui/desktop/tests/integration/components/settings-card/user-test.js
@@ -25,6 +25,10 @@ module('Integration | Component | settings-card/user', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');
+ hooks.beforeEach(function () {
+ this.set('application', { showModalOrLogout: () => {} });
+ });
+
test('it renders password correctly', async function (assert) {
this.owner.register(
'service:session',
@@ -34,7 +38,9 @@ module('Integration | Component | settings-card/user', function (hooks) {
},
);
- await render(hbs``);
+ await render(
+ hbs``,
+ );
assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated');
assert.dom(AUTH_METHOD_TYPE).hasText('Password');
@@ -52,7 +58,9 @@ module('Integration | Component | settings-card/user', function (hooks) {
},
);
- await render(hbs``);
+ await render(
+ hbs``,
+ );
assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated');
assert.dom(AUTH_METHOD_TYPE).hasText('OIDC');
@@ -70,7 +78,9 @@ module('Integration | Component | settings-card/user', function (hooks) {
},
);
- await render(hbs``);
+ await render(
+ hbs``,
+ );
assert.dom(AUTHENTICATION_BADGE).hasText('Authenticated');
assert.dom(AUTH_METHOD_TYPE).hasText('LDAP');
diff --git a/ui/desktop/tests/unit/controllers/application-test.js b/ui/desktop/tests/unit/controllers/application-test.js
index 7f4b66360..90b985500 100644
--- a/ui/desktop/tests/unit/controllers/application-test.js
+++ b/ui/desktop/tests/unit/controllers/application-test.js
@@ -12,9 +12,9 @@ module('Unit | Controller | application', function (hooks) {
setupTest(hooks);
let controller;
- let session;
let clusterUrl;
let mockIPC;
+ let session;
const setupMockIpc = (test) => {
test.owner.register('service:browser/window', WindowMockIPC);
@@ -23,10 +23,10 @@ module('Unit | Controller | application', function (hooks) {
hooks.beforeEach(async function () {
await authenticateSession({});
+ setupMockIpc(this);
controller = this.owner.lookup('controller:application');
session = this.owner.lookup('service:session');
clusterUrl = this.owner.lookup('service:cluster-url');
- setupMockIpc(this);
});
test('it exists', function (assert) {
@@ -34,14 +34,8 @@ module('Unit | Controller | application', function (hooks) {
assert.ok(controller.minimize);
assert.ok(controller.toggleFullScreen);
assert.ok(controller.close);
- });
-
- test('invalidateSession action de-authenticates a user', async function (assert) {
- assert.true(session.isAuthenticated);
-
- await controller.invalidateSession();
-
- assert.false(session.isAuthenticated);
+ assert.ok(controller.confirmCloseSessions);
+ assert.ok(controller.showModalOrLogout);
});
test('toggleTheme action sets theme to specified value', function (assert) {
diff --git a/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js b/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js
index 5650f86c9..c398245f5 100644
--- a/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js
+++ b/ui/desktop/tests/unit/controllers/scopes/scope/projects/sessions/index-test.js
@@ -132,7 +132,7 @@ module(
assert.deepEqual(controller.availableSessions, selectedItems);
});
- test('cancelSession action action updates session status', async function (assert) {
+ test('cancelSession action updates session status', async function (assert) {
await visit(urls.sessions);
const session = await store.findRecord('session', instances.session.id);