From fa591fb7ad2642eec706c7cf29513e83e3a80d0c Mon Sep 17 00:00:00 2001 From: Alex Robinson Date: Mon, 2 Mar 2026 11:35:38 +0000 Subject: [PATCH] NOJIRA sign out of all synchronized tabs Fixes https://github.com/hmrc/hmrc-frontend/issues/522 --- CHANGELOG.md | 8 ++++ package-lock.json | 4 +- package.json | 2 +- .../session-activity-service.js | 7 +++ .../session-activity-service.test.js | 15 +++++++ src/components/timeout-dialog/template.njk | 1 + .../timeout-dialog/timeout-dialog.js | 12 +++++ .../timeout-dialog.jsdom.test.js | 8 ++++ .../timeout-dialog/timeout-dialog.yaml | 8 ++++ .../timeout-multiple-tabs.browser.test.js | 44 +++++++++++++++++++ 10 files changed, 106 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ce3421..78d0120c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [6.118.0] - 2026-03-02 + +### Changed + +- Sign user out of all synchronized tabs + +## [6.117.0] - 2026-03-02 + ## [6.117.0] - 2026-03-02 ### Changed diff --git a/package-lock.json b/package-lock.json index 35f116bb..1d465961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hmrc-frontend", - "version": "6.117.0", + "version": "6.118.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hmrc-frontend", - "version": "6.117.0", + "version": "6.118.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index c5a4ebca..b43e6b1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hmrc-frontend", - "version": "6.117.0", + "version": "6.118.0", "description": "Design patterns for HMRC frontends", "scripts": { "start": "gulp dev", diff --git a/src/components/timeout-dialog/session-activity-service.js b/src/components/timeout-dialog/session-activity-service.js index be40293e..bae81237 100644 --- a/src/components/timeout-dialog/session-activity-service.js +++ b/src/components/timeout-dialog/session-activity-service.js @@ -10,6 +10,13 @@ export default class SessionActivityService { } } + logSignedOut() { + if (this.activityChannel) { + const event = { signedOut: true }; + this.activityChannel.postMessage(event); + } + } + onActivity(callback) { if (this.activityChannel) { this.activityChannel.onmessage = (event) => { diff --git a/src/components/timeout-dialog/session-activity-service.test.js b/src/components/timeout-dialog/session-activity-service.test.js index aef37eeb..d9ea131b 100644 --- a/src/components/timeout-dialog/session-activity-service.test.js +++ b/src/components/timeout-dialog/session-activity-service.test.js @@ -38,6 +38,21 @@ describe('/components/timeout-dialog/session-activity-service', () => { }); }); + describe('logSignedOut', () => { + it('should post an event containing signedOut status to the activity channel', () => { + const sut = new SessionActivityService(mockBroadcastChannelFactory); + sut.logSignedOut(); + expect(mockBroadcastChannel.postMessage) + .toHaveBeenCalledWith({ signedOut: true }); + }); + describe('when BroadcastChannel is undefined', () => { + it('should do nothing', () => { + const sut = new SessionActivityService(undefined); + expect(() => sut.logSignedOut).not.toThrow(); + }); + }); + }); + describe('onActivity', () => { it('should register the passed callback fn with the broadcast channel', () => { const sut = new SessionActivityService(mockBroadcastChannelFactory); diff --git a/src/components/timeout-dialog/template.njk b/src/components/timeout-dialog/template.njk index 788060f7..29007884 100644 --- a/src/components/timeout-dialog/template.njk +++ b/src/components/timeout-dialog/template.njk @@ -13,4 +13,5 @@ data-keep-alive-button-text="{{ params.keepAliveButtonText }}" data-sign-out-button-text="{{ params.signOutButtonText }}" data-synchronise-tabs="{{ params.synchroniseTabs }}" + data-synchronise-tabs-signout="{{ params.synchroniseTabsSignout }}" data-hide-sign-out-button="{{ params.hideSignOutButton }}"/> diff --git a/src/components/timeout-dialog/timeout-dialog.js b/src/components/timeout-dialog/timeout-dialog.js index 59eb7817..bfeb7285 100644 --- a/src/components/timeout-dialog/timeout-dialog.js +++ b/src/components/timeout-dialog/timeout-dialog.js @@ -11,6 +11,7 @@ function TimeoutDialog($module, $sessionActivityService) { const cleanupFunctions = []; let currentTimer; const sessionActivityService = $sessionActivityService; + let signedOut = false; function init() { const validate = ValidateInput; @@ -61,6 +62,9 @@ function TimeoutDialog($module, $sessionActivityService) { synchroniseTabs: validate.boolean( lookupData('data-synchronise-tabs') || false, ), + synchroniseTabsSignout: validate.boolean( + lookupData('data-synchronise-tabs-signout') || false, + ), hideSignOutButton: validate.boolean( lookupData('data-hide-sign-out-button') || false, ), @@ -82,6 +86,12 @@ function TimeoutDialog($module, $sessionActivityService) { const listenForSessionActivityAndResetDialogTimer = () => { if (settings.synchroniseTabs) { sessionActivityService.onActivity((event) => { + if (event.signedOut) { + if (!signedOut && settings.synchroniseTabsSignout) { + signOut(); + return; + } + } const timeOfActivity = event.timestamp; cleanup(); setupDialogTimer(timeOfActivity); @@ -285,6 +295,8 @@ function TimeoutDialog($module, $sessionActivityService) { const getDateNow = () => Date.now(); const signOut = () => { + signedOut = true; + sessionActivityService.logSignedOut(); RedirectHelper.redirectToUrl(settings.signOutUrl); }; diff --git a/src/components/timeout-dialog/timeout-dialog.jsdom.test.js b/src/components/timeout-dialog/timeout-dialog.jsdom.test.js index ef4bcd8f..71287c28 100644 --- a/src/components/timeout-dialog/timeout-dialog.jsdom.test.js +++ b/src/components/timeout-dialog/timeout-dialog.jsdom.test.js @@ -39,6 +39,7 @@ describe('/components/timeout-dialog', () => { SessionActivityService.mockImplementation(() => ({ logActivity: jest.fn(), + logSignedOut: jest.fn(), onActivity: jest.fn(), })); const mockSessionActivityService = new SessionActivityService(); @@ -122,6 +123,7 @@ describe('/components/timeout-dialog', () => { utils.ajaxGet.mockReset(); redirectHelper.redirectToUrl.mockReset(); mockSessionActivityService.onActivity.mockReset(); + mockSessionActivityService.logSignedOut.mockReset(); jest.clearAllTimers(); }); @@ -196,6 +198,12 @@ describe('/components/timeout-dialog', () => { expect(redirectHelper.redirectToUrl).toHaveBeenCalledWith('/sign-out'); }); + it('should broadcast the signed out status when sign out is clicked', () => { + assume(mockSessionActivityService.logSignedOut).not.toHaveBeenCalled(); + clickElem(testScope.latestDialog$element.querySelector('#hmrc-timeout-sign-out-link')); + expect(mockSessionActivityService.logSignedOut).toHaveBeenCalled(); + }); + it('should use the sign out url on the sign out link', () => { const $signoutLink = testScope.latestDialog$element.querySelector('a#hmrc-timeout-sign-out-link'); expect($signoutLink.attributes.getNamedItem('href').value).toEqual('/sign-out'); diff --git a/src/components/timeout-dialog/timeout-dialog.yaml b/src/components/timeout-dialog/timeout-dialog.yaml index f986f782..14a0bfea 100644 --- a/src/components/timeout-dialog/timeout-dialog.yaml +++ b/src/components/timeout-dialog/timeout-dialog.yaml @@ -64,6 +64,14 @@ examples: keepAliveUrl: "?abc=def" signOutUrl: "?ghi=jkl" synchroniseTabs: true +- name: synchronise-tabs-signout + data: + timeout: 75 + countdown: 73 + keepAliveUrl: "?abc=def" + signOutUrl: "?ghi=jkl" + synchroniseTabs: true + synchroniseTabsSignout: true - name: timeout-warnings-not-synchronised data: timeout: 75 diff --git a/src/components/timeout-dialog/timeout-multiple-tabs.browser.test.js b/src/components/timeout-dialog/timeout-multiple-tabs.browser.test.js index 9fac6377..8f22c20e 100644 --- a/src/components/timeout-dialog/timeout-multiple-tabs.browser.test.js +++ b/src/components/timeout-dialog/timeout-multiple-tabs.browser.test.js @@ -1,7 +1,20 @@ import 'expect-puppeteer'; +import { + clockTickSeconds, + useFakeTimers, +} from '../../../lib/browser-tests/puppeteer-helpers'; + import { examplePreview } from '../../../lib/url-helpers'; +async function navigateToPage(session, url, advance = 10) { + const page = await session.newPage(); + await useFakeTimers(page); + await page.goto(examplePreview(url)); + await clockTickSeconds(page, advance); + return page; +} + describe('multiple tabs open with synchronise tabs feature switch enabled', () => { it('should keep other synchronised tabs alive when the user chooses to extend their session', async () => { const session = await browser.createBrowserContext(); @@ -51,4 +64,35 @@ describe('multiple tabs open with synchronise tabs feature switch enabled', () = await session.close(); }); + + it('should sign out of all synchronised tabs when the user chooses to sign out early', async () => { + const synchronisedTabsSignoutUrl = 'timeout-dialog/synchronise-tabs-signout'; + const synchronisedTabsUrl = 'timeout-dialog/synchronise-tabs'; + const unsynchronisedTabsSignoutUrl = 'timeout-dialog/timeout-warnings-not-synchronised'; + const signoutUrl = '?ghi=jkl'; + + const session = await browser.createBrowserContext(); + + const nonSynchronisedPage = await navigateToPage(session, unsynchronisedTabsSignoutUrl); + const synchronisedNoSignoutPage = await navigateToPage( + session, + synchronisedTabsUrl, + ); + const backgroundPage = await navigateToPage(session, synchronisedTabsSignoutUrl); + const foregroundPage = await navigateToPage(session, synchronisedTabsSignoutUrl); + + await expect(foregroundPage).toClick('a', { text: 'Sign out' }); + + // foregorund should be signed out + await foregroundPage.waitForNavigation({ timeout: 2000 }); + await expect(foregroundPage.url()).toMatch(signoutUrl); + // background should be signed out + await backgroundPage.waitForNavigation({ timeout: 5000 }); + await expect(backgroundPage.url()).toMatch(signoutUrl); + // but non-synchronised and synchronised without signout pages should still be signed in + await expect(nonSynchronisedPage.url()).toMatch(unsynchronisedTabsSignoutUrl); + await expect(synchronisedNoSignoutPage.url()).toMatch(synchronisedTabsUrl); + + await session.close(); + }); });