diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c967de..68ef9a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2024, Version 22.0.0, @Rhodine-orleans-lindsay +* Adds session timeout warning + - user can stay on page or exit form + - adds exit html + - updates confirmation html to a static page + - allows for customisation of session timeout warning dialog content and exit page content +* Fixes accessibility issues +* Sandbox area for testing hof changes +* Updates patch and minor dependency versions + ## 2024-07-22, Version 21.0.0 (Stable), @Rhodine-orleans-lindsay * Replaces deprecated request module with axios - refactors the hof model and apis to use axios instead of request diff --git a/README.md b/README.md index feb33c19..d44f4fb5 100644 --- a/README.md +++ b/README.md @@ -945,11 +945,11 @@ Using the translation key `fields.field-name.label` will return different values # HOF Components -## Date Component +## Date Component A component for handling the rendering and processing of 3-input date fields used in HOF Applications. -## Usage +### Usage In your fields config: @@ -965,7 +965,7 @@ module.exports = { The above example will create a new date component with the key `'date-field'` and will apply the validators `required` and `before` (before today). -## Configuration +### Configuration The following optional configuration options are supported: @@ -974,7 +974,7 @@ The following optional configuration options are supported: - `dayOptional {Boolean}` - day defaults to `01` if omitted. Defaults to `false` - `monthOptional {Boolean}` - month defaults to `01` if omitted. If true then also forces `dayOptional` to be true. Defaults to `false` -## Labels +### Labels The three intermedate fields have fallback labels of Day, Month and Year, however custom labels can be used by including the translation at the following path: @@ -998,13 +998,13 @@ fields.json } ``` -# Summary Page Component +## Summary Page Component HOF behaviour for showing summary pages The behaviour mixin will create a set of "locals" data which is compatible with [the `confirm` view from `hof-template-partials`](https://github.com/UKHomeOfficeForms/hof-template-partials/blob/master/views/confirm.html). -## Usage +### Usage If no sections config is passed, then the mixin will create a section for each step that has fields, and a row within each section for each field on that step. @@ -1043,11 +1043,11 @@ Alternatively, sections can be defined manually as follows: } ``` -## Configuration +### Configuration The `sections` configuration should be a map of arrays, where the entries in the array are the fields that should be shown within that section. -### Field configuration +#### Field configuration Fields can be defined as simple strings of the field key, in which case all default configuration will be used. @@ -1087,18 +1087,18 @@ The `location-addresses` field is one that the application has setup to aggregat This allows the creation of summary rows based on unknown dynamic user input, i.e. we can not predict in advance how many addresses a user wants to input, what the addresses are and how many categories the user wants to attach to each address. This allows you to easily list them this way. -## Translations +### Translations The content for section headings and field labels will be loaded from translation files based on the keys. -### Section headings +#### Section headings Translations for section headings are looked for in the following order: - `pages.confirm.sections.${key}.header` - `pages.${key}.header` -### Field labels +#### Field labels Translations for field labels are looked for in the following order: @@ -1106,11 +1106,11 @@ Translations for field labels are looked for in the following order: - `fields.${key}.label` - `fields.${key}.legend` -# Emailer Component +## Emailer Component HOF behaviour to send emails -## Usage +### Usage ```js const EmailBehaviour = require('hof').components.emailer; @@ -1140,7 +1140,7 @@ steps: { } ``` -## Options +### Options In addition to the options passed to `hof-emailer`, the following options can be used: @@ -1159,17 +1159,17 @@ const emailer = EmailBehaviour({ }); ``` -# HOF Emailer +## HOF Emailer An emailer service for HOF applications. -## Installation +### Installation ```bash $ npm install hof-emailer --save ``` -## Usage +### Usage ```js // first create an emailer instance @@ -1192,22 +1192,22 @@ emailer.send(to, body, subject).then(() => { }); ``` -## Options +### Options - `from`: : Address to send emails from. Required. - `transport`: : Select what mechanism to use to send emails. Defaults: 'smtp'. - `transportOptions`: : Set the options for the chosen transport, as defined below. Required. - `layout`: : Optional path to use a custom layout for email content. -## Transports +### Transports The following transport options are available: -### `smtp` +#### `smtp` [nodemailer-smtp-transport](https://github.com/andris9/nodemailer-smtp-transport) -#### Options +##### Options - `host` : Address of the mailserver. Required. - `port` : Port of the mailserver. Required. @@ -1216,11 +1216,11 @@ The following transport options are available: - `auth.user` : Mailserver authorisation username. - `auth.pass` : Mailserver authorisation password. -### `ses` +#### `ses` [nodemailer-ses-transport](https://github.com/andris9/nodemailer-ses-transport) -#### Options +##### Options - `accessKeyId` : AWS accessKeyId. Required. - `secretAccessKey` : AWS accessKeyId. Required. @@ -1230,18 +1230,18 @@ The following transport options are available: - `rateLimit` - `maxConnections` -### `debug` +#### `debug` A development option to write the html content of the email to a file for inspection. `transport: 'debug'` -#### debug options +##### debug options - `dir` : The location to save html to. Default: `./.emails`. This directory will be created if it does not exist. - `open` : If set to true, will automatically open the created html file in a browser. -#### debug example +##### debug example ``` transport: 'debug' @@ -1251,10 +1251,57 @@ transportOptions: { } ``` -### `stub` +#### `stub` Disables sending email. No options are required. +## Session Timeout Warning Component +HOF component for customising session timeout related pages +This feature allows you to customise the content related to the session timeout warning, including the messages displayed in the session timeout warning dialog and on the exit page after a user exits the form due to a session timeout. + +### Usage + +To enable and customize the session timeout behavior, you need to set the component in your project's `hof.settings.json` file: +```js + "behaviours": [ + "hof/components/session-timeout-warning" + ] +``` + +By default, the framework uses the standard content provided by HOF. If you wish to override this with custom content at the project level, you must set the following variables to `true` in `hof.settings.json`: + +```js + behaviours: [ + require('../').components.sessionTimeoutWarning + ], + sessionTimeoutWarningContent: true, + exitFormContent: true +``` + +### Customising content in `pages.json` +Once the variables are set, you can customize the session timeout warning and exit messages in your project's pages.json: + +```json +"exit": { + "message": "We have cleared your information to keep it secure. Your information has not been saved." +}, +"session-timeout-warning": { + "dialog-title": "Your application will close soon", + "dialog-text": "If that happens, your progress will not be saved.", + "timeout-continue-button": "Stay on this page", + "dialog-exit-link": "Exit this form" +} +``` + +### Editing content on the Exit Page Header and Title +To edit the exit page's header and title, create an `exit.json` file in your project and set the desired content: +```json +{ + "header": "You have left this form", + "title": "You have left this form" +} +``` + # UTILITIES # Autofill Utility diff --git a/components/index.js b/components/index.js index a77fb5fa..9ed98ad2 100644 --- a/components/index.js +++ b/components/index.js @@ -8,5 +8,6 @@ module.exports = { emailer: require('./emailer'), homeOfficeCountries: require('./homeoffice-countries'), notify: require('./notify'), - summary: require('./summary') + summary: require('./summary'), + sessionTimeoutWarning: require('./session-timeout-warning') }; diff --git a/components/session-timeout-warning/index.js b/components/session-timeout-warning/index.js new file mode 100644 index 00000000..45ccec41 --- /dev/null +++ b/components/session-timeout-warning/index.js @@ -0,0 +1,56 @@ +/** + * + * @fileOverview + * Provides custom behavior for handling session timeout warnings and exit actions. This includes + * - Resetting the session if the user exits due to a session timeout. + * - Customizing the session timeout warning dialog content. + * - Setting custom content and titles on the exit page. + * + * @module SessionTimeoutWarningBehavior + * @requires ../../config/hof-defaults + * @param {Class} superclass - The class to be extended. + * @returns {Class} - The extended class with session timeout handling functionality. + */ + +'use strict'; +const config = require('../../config/hof-defaults'); +const logger = require('../../lib/logger')(config); + +module.exports = superclass => class extends superclass { + configure(req, res, next) { + try { + // Reset the session if the user chooses to exit on session timeout warning + if (req.form.options.route === '/exit') { + req.sessionModel.reset(); + logger.log('info', 'Session has been reset on exit'); + } + return super.configure(req, res, next); + } catch (error) { + logger.error('Error during session reset:', error); + return next(error); // Pass the error to the next middleware for centralised handling + } + } + + locals(req, res) { + // set the custom session dialog message + const superLocals = super.locals(req, res); + if (res.locals.sessionTimeoutWarningContent === true) { + superLocals.dialogTitle = true; + superLocals.dialogText = true; + superLocals.timeoutContinueButton = true; + superLocals.dialogExitLink = true; + } + + // set the content on /exit page + if (req.form.options.route === '/exit' && config.exitFormContent === true) { + superLocals.exitFormContent = true; + return superLocals; + } else if (req.form.options.route === '/exit' && config.exitFormContent === false) { + superLocals.header = req.translate('exit.header'); + superLocals.title = req.translate('exit.title'); + superLocals.message = req.translate('exit.message'); + return superLocals; + } + return superLocals; + } +}; diff --git a/config/hof-defaults.js b/config/hof-defaults.js index 9962792b..3b6d6152 100644 --- a/config/hof-defaults.js +++ b/config/hof-defaults.js @@ -14,6 +14,8 @@ const defaults = { getCookies: true, getTerms: true, getAccessibility: false, + sessionTimeoutWarningContent: false, + exitFormContent: false, viewEngine: 'html', protocol: process.env.PROTOCOL || 'http', noCache: process.env.NO_CACHE || false, @@ -47,7 +49,8 @@ const defaults = { apis: { pdfConverter: process.env.PDF_CONVERTER_URL }, - serveStatic: process.env.SERVE_STATIC_FILES !== 'false' + serveStatic: process.env.SERVE_STATIC_FILES !== 'false', + sessionTimeOutWarning: process.env.SESSION_TIMEOUT_WARNING || 300 }; module.exports = Object.assign({}, defaults, rateLimits); diff --git a/frontend/template-partials/translations/src/en/exit.json b/frontend/template-partials/translations/src/en/exit.json new file mode 100644 index 00000000..a098ce82 --- /dev/null +++ b/frontend/template-partials/translations/src/en/exit.json @@ -0,0 +1,5 @@ +{ + "header": "You have left this form", + "title": "You have left this form", + "message": "We have cleared your information to keep it secure. Your information has not been saved." +} diff --git a/frontend/template-partials/views/confirmation.html b/frontend/template-partials/views/confirmation.html index bf8a6e1b..7e9e2d08 100644 --- a/frontend/template-partials/views/confirmation.html +++ b/frontend/template-partials/views/confirmation.html @@ -1,6 +1,19 @@ -{{ partials-navigation}} + {{/propositionHeader}} + + {{$header}} + {{header}} + {{/header}} + + {{$content}} {{>partials-confirmation-alert}} {{#markdown}}what-happens-next{{/markdown}} - {{/page-content}} -{{/partials-page}} + {{/content}} +{{/layout}} + diff --git a/frontend/template-partials/views/exit.html b/frontend/template-partials/views/exit.html new file mode 100644 index 00000000..93b28135 --- /dev/null +++ b/frontend/template-partials/views/exit.html @@ -0,0 +1,9 @@ +{{{{#exitFormContent}}{{#t}}pages.exit.message{{/t}}{{/exitFormContent}}{{^exitFormContent}}{{#t}}{{message}}{{/t}}{{/exitFormContent}} + {{#t}}buttons.start-again{{/t}} + {{/content}} +{{/layout}} diff --git a/frontend/template-partials/views/partials/head.html b/frontend/template-partials/views/partials/head.html index 68487c25..63670a17 100644 --- a/frontend/template-partials/views/partials/head.html +++ b/frontend/template-partials/views/partials/head.html @@ -25,4 +25,7 @@ {{/deIndex}} + diff --git a/frontend/template-partials/views/partials/page.html b/frontend/template-partials/views/partials/page.html index 8fc88e68..633ae5f7 100644 --- a/frontend/template-partials/views/partials/page.html +++ b/frontend/template-partials/views/partials/page.html @@ -16,6 +16,7 @@ {{ partials-session-timeout-warning}} {{/form}} {{/partials-form}} {{/content}} diff --git a/frontend/template-partials/views/partials/session-timeout-warning.html b/frontend/template-partials/views/partials/session-timeout-warning.html new file mode 100644 index 00000000..5f7f36ac --- /dev/null +++ b/frontend/template-partials/views/partials/session-timeout-warning.html @@ -0,0 +1,22 @@ +
+

We will reset your application if you do not complete the page and press continue + in 5 minutes.

+

We do this to keep your information secure.

+
+ + + + diff --git a/frontend/themes/gov-uk/client-js/index.js b/frontend/themes/gov-uk/client-js/index.js index 9d2e4f0a..fbff2181 100644 --- a/frontend/themes/gov-uk/client-js/index.js +++ b/frontend/themes/gov-uk/client-js/index.js @@ -14,6 +14,7 @@ window.GOVUK = GOVUK; var skipToMain = require('./skip-to-main'); var cookie = require('./govuk-cookies'); var cookieSettings = require('./cookieSettings'); +var sessionDialog = require('./session-timeout-dialog'); toolkit.detailsSummary(); diff --git a/frontend/themes/gov-uk/client-js/session-timeout-dialog.js b/frontend/themes/gov-uk/client-js/session-timeout-dialog.js new file mode 100644 index 00000000..935f6274 --- /dev/null +++ b/frontend/themes/gov-uk/client-js/session-timeout-dialog.js @@ -0,0 +1,348 @@ +/* eslint max-len: 0 */ +'use strict'; + +const $ = require('jquery'); +window.dialogPolyfill = require('dialog-polyfill'); + +// Modal dialog prototype +window.GOVUK.sessionDialog = { + el: document.getElementById('js-modal-dialog'), + $el: $('#js-modal-dialog'), + $lastFocusedEl: null, + $closeButton: $('.modal-dialog .js-dialog-close'), + $fallBackElement: $('.govuk-timeout-warning-fallback'), + dialogIsOpenClass: 'dialog-is-open', + timers: [], + warningTextPrefix: 'To protect your information, this page will time out in ', + warningTextSuffix: '.', + warningText: $('.dialog-text').text(), + warningTextExtra: '', + + // Timer specific markup. If these are not present, timeout and redirection are disabled + $timer: $('#js-modal-dialog .timer'), + $accessibleTimer: $('#js-modal-dialog .at-timer'), + + secondsSessionTimeout: parseInt($('#js-modal-dialog').data('session-timeout'), 10 || 1800), + secondsTimeoutWarning: parseInt($('#js-modal-dialog').data('session-timeout-warning'), 10 || 300), + timeoutRedirectUrl: $('#js-modal-dialog').data('url-redirect'), + timeSessionRefreshed: new Date(), + + bindUIElements: function () { + window.GOVUK.sessionDialog.$closeButton.on('click', function (e) { + e.preventDefault(); + window.GOVUK.sessionDialog.closeDialog(); + }); + + // Close modal when ESC pressed + $(document).keydown(function (e) { + if (window.GOVUK.sessionDialog.isDialogOpen() && e.keyCode === 27) { + window.GOVUK.sessionDialog.closeDialog(); + } + }); + }, + + isDialogOpen: function () { + return window.GOVUK.sessionDialog.el && window.GOVUK.sessionDialog.el.open; + }, + + isConfigured: function () { + return window.GOVUK.sessionDialog.$timer.length > 0 && + window.GOVUK.sessionDialog.$accessibleTimer.length > 0 && + window.GOVUK.sessionDialog.secondsSessionTimeout && + window.GOVUK.sessionDialog.secondsTimeoutWarning && + window.GOVUK.sessionDialog.timeoutRedirectUrl; + }, + + openDialog: function () { + if (!window.GOVUK.sessionDialog.isDialogOpen()) { + $('html, body').addClass(window.GOVUK.sessionDialog.dialogIsOpenClass); + window.GOVUK.sessionDialog.saveLastFocusedEl(); + window.GOVUK.sessionDialog.makePageContentInert(); + window.GOVUK.sessionDialog.el.showModal(); + window.GOVUK.sessionDialog.el.open = true; + } + }, + + closeDialog: function () { + if (window.GOVUK.sessionDialog.isDialogOpen()) { + $('html, body').removeClass(window.GOVUK.sessionDialog.dialogIsOpenClass); + window.GOVUK.sessionDialog.el.close(); + window.GOVUK.sessionDialog.el.open = false; + window.GOVUK.sessionDialog.setFocusOnLastFocusedEl(); + window.GOVUK.sessionDialog.removeInertFromPageContent(); + window.GOVUK.sessionDialog.refreshSession(); + } + }, + + saveLastFocusedEl: function () { + window.GOVUK.sessionDialog.$lastFocusedEl = document.activeElement; + if (!window.GOVUK.sessionDialog.$lastFocusedEl || window.GOVUK.sessionDialog.$lastFocusedEl === document.body) { + window.GOVUK.sessionDialog.$lastFocusedEl = null; + } else if (document.querySelector) { + window.GOVUK.sessionDialog.$lastFocusedEl = document.querySelector(':focus'); + } + }, + + // Set focus back on last focused el when modal closed + setFocusOnLastFocusedEl: function () { + if (window.GOVUK.sessionDialog.$lastFocusedEl) { + window.setTimeout(function () { + window.GOVUK.sessionDialog.$lastFocusedEl.focus(); + }, 0); + } + }, + + // Set page content to inert to indicate to screenreaders it's inactive + // NB: This will look for #content for toggling inert state + makePageContentInert: function () { + if (document.querySelector('#content')) { + document.querySelector('#content').inert = true; + document.querySelector('#content').setAttribute('aria-hidden', 'true'); + } + }, + + // Make page content active when modal is not open + // NB: This will look for #content for toggling inert state + removeInertFromPageContent: function () { + if (document.querySelector('#content')) { + document.querySelector('#content').inert = false; + document.querySelector('#content').setAttribute('aria-hidden', 'false'); + } + }, + + numberToWords: function (n) { + const string = n.toString(); + let start; + let end; + let chunk; + let ints; + let i; + let words = 'and'; + + if (parseInt(string, 10) === 0) { + return 'zero'; + } + + /* Array of units as words */ + const units = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']; + + /* Array of tens as words */ + const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; + + /* Array of scales as words */ + const scales = ['', 'thousand', 'million', 'billion', 'trillion', 'quadrillion', 'quintillion', 'sextillion', 'septillion', 'octillion', 'nonillion', 'decillion', 'undecillion', 'duodecillion', 'tredecillion', 'quatttuor-decillion', 'quindecillion', 'sexdecillion', 'septen-decillion', 'octodecillion', 'novemdecillion', 'vigintillion', 'centillion']; + + /* Split user argument into 3 digit chunks from right to left */ + start = string.length; + const chunks = []; + while (start > 0) { + end = start; + chunks.push(string.slice((start = Math.max(0, start - 3)), end)); + } + + /* Check if function has enough scale words to be able to stringify the user argument */ + const chunksLen = chunks.length; + if (chunksLen > scales.length) { + return ''; + } + + /* Stringify each integer in each chunk */ + words = []; + for (i = 0; i < chunksLen; i++) { + chunk = parseInt(chunks[i], 10); + + if (chunk) { + /* Split chunk into array of individual integers */ + ints = chunks[i].split('').reverse().map(parseFloat); + + /* If tens integer is 1, i.e. 10, then add 10 to units integer */ + if (ints[1] === 1) { + ints[0] += 10; + } + + /* Add scale word if chunk array item exists */ + if (scales[i]) { + words.push(scales[i]); + } + + /* Add unit word if array item exists */ + if (units[ints[0]]) { + words.push(units[ints[0]]); + } + + /* Add tens word if array item exists */ + if (tens[ints[1]]) { + words.push(tens[ints[1]]); + } + + /* Add hundreds word if array item exists */ + if (units[ints[2]]) { + words.push(units[ints[2]] + ' hundred'); + } + } + } + return words.reverse().join(' '); + }, + + // Attempt to convert numerics into text as OS VoiceOver + // occasionally stalled when encountering numbers + timeToWords: function (t, unit) { + let words; + if (t > 0) { + try { + words = window.GOVUK.sessionDialog.numberToWords(t); + } catch (e) { + words = t; + } + words = words + ' ' + window.GOVUK.sessionDialog.pluralise(t, unit); + } else { + words = ''; + } + return words; + }, + + pluralise: function (n, unit) { + return n === 1 ? unit : unit + 's'; + }, + + numericSpan: function (n, unit) { + return '' + n + ' ' + window.GOVUK.sessionDialog.pluralise(n, unit); + }, + + countdownText: function (minutes, seconds) { + const minutesText = window.GOVUK.sessionDialog.numericSpan(minutes, 'minute'); + const secondsText = window.GOVUK.sessionDialog.numericSpan(seconds, 'second'); + return minutes > 0 ? minutesText : secondsText; + }, + + countdownAtText: function (minutes, seconds) { + const minutesText = window.GOVUK.sessionDialog.timeToWords(minutes, 'minute'); + const secondsText = window.GOVUK.sessionDialog.timeToWords(seconds, 'second'); + return minutes > 0 ? minutesText : secondsText; + }, + + startCountdown: function () { + const $timer = window.GOVUK.sessionDialog.$timer; + const $accessibleTimer = window.GOVUK.sessionDialog.$accessibleTimer; + let timerRunOnce = false; + const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + + const seconds = window.GOVUK.sessionDialog.secondsUntilSessionTimeout(); + const minutes = seconds / 60; + + $timer.text(minutes + ' minute' + (minutes > 1 ? 's' : '')); + + (function countdown() { + const secondsUntilSessionTimeout = window.GOVUK.sessionDialog.secondsUntilSessionTimeout(); + const timerExpired = secondsUntilSessionTimeout <= 0; + + if (!timerExpired) { + const minutesLeft = parseInt(secondsUntilSessionTimeout / 60, 10); + const secondsLeft = parseInt(secondsUntilSessionTimeout % 60, 10); + + // Below string will get read out by screen readers every time + // the timeout refreshes. + // Add additional information in extraText which will get announced to AT the + // first time the time out opens + const countdownText = window.GOVUK.sessionDialog.countdownText(minutesLeft, secondsLeft); + const text = window.GOVUK.sessionDialog.warningTextPrefix + '' + countdownText + '' + window.GOVUK.sessionDialog.warningTextSuffix + '

' + window.GOVUK.sessionDialog.warningText + '

'; + const countdownAtText = window.GOVUK.sessionDialog.countdownAtText(minutesLeft, secondsLeft); + const atText = window.GOVUK.sessionDialog.warningTextPrefix + countdownAtText + window.GOVUK.sessionDialog.warningTextSuffix + ' ' + window.GOVUK.sessionDialog.warningText; + const extraText = '\n' + window.GOVUK.sessionDialog.warningTextExtra; + + $timer.html(text + ' ' + extraText); + + // Update screen reader friendly content every 20 secs + if (secondsLeft % 20 === 0) { + // Read out the extra content only once. + // Don't read out on iOS VoiceOver which stalls on the longer text + if (!timerRunOnce && !iOS) { + $accessibleTimer.text(atText + extraText); + timerRunOnce = true; + } else { + $accessibleTimer.text(atText); + } + } + + window.GOVUK.sessionDialog.addTimer(countdown, 20); + } + })(); + }, + + // Clears all timers + clearTimers: function () { + for (let i = 0; i < window.GOVUK.sessionDialog.timers.length; i++) { + clearInterval(window.GOVUK.sessionDialog.timers[i]); + } + }, + + refreshSession: function () { + $.get(''); + window.GOVUK.sessionDialog.timeSessionRefreshed = new Date(); + window.GOVUK.sessionDialog.controller(); + }, + + redirect: function () { + window.location = window.GOVUK.sessionDialog.timeoutRedirectUrl; + }, + + // JS doesn't allow resetting timers globally so timers need + // to be retained for resetting. + addTimer: function (f, seconds) { + window.GOVUK.sessionDialog.timers.push(setInterval(f, seconds * 1000)); + }, + + secondsSinceRefresh: function () { + return Math.round(Math.abs((window.GOVUK.sessionDialog.timeSessionRefreshed - new Date()) / 1000)); + }, + + secondsUntilSessionTimeout: function () { + return window.GOVUK.sessionDialog.secondsSessionTimeout - window.GOVUK.sessionDialog.secondsSinceRefresh(); + }, + + secondsUntilTimeoutWarning: function () { + return window.GOVUK.sessionDialog.secondsUntilSessionTimeout() - window.GOVUK.sessionDialog.secondsTimeoutWarning; + }, + + // countdown controller logic + controller: function () { + window.GOVUK.sessionDialog.clearTimers(); + + const secondsUntilSessionTimeout = window.GOVUK.sessionDialog.secondsUntilSessionTimeout(); + + if (secondsUntilSessionTimeout <= 0) { + // timed out - redirect + window.GOVUK.sessionDialog.redirect(); + } else if (secondsUntilSessionTimeout <= window.GOVUK.sessionDialog.secondsTimeoutWarning) { + // timeout warning - show countdown and schedule redirect + window.GOVUK.sessionDialog.openDialog(); + window.GOVUK.sessionDialog.startCountdown(); + window.GOVUK.sessionDialog.addTimer(window.GOVUK.sessionDialog.controller, window.GOVUK.sessionDialog.secondsUntilSessionTimeout()); + } else { + // wait for warning + window.GOVUK.sessionDialog.addTimer(window.GOVUK.sessionDialog.controller, window.GOVUK.sessionDialog.secondsUntilTimeoutWarning()); + } + }, + + init: function (options) { + $.extend(window.GOVUK.sessionDialog, options); + if (window.GOVUK.sessionDialog.el && window.GOVUK.sessionDialog.isConfigured()) { + // Native dialog is not supported by some browsers so use polyfill + if (typeof HTMLDialogElement !== 'function') { + try { + window.dialogPolyfill.registerDialog(window.GOVUK.sessionDialog.el); + return true; + } catch (error) { + // Doesn't support polyfill (IE8) - display fallback element + window.GOVUK.sessionDialog.$fallBackElement.classList.add('govuk-!-display-block'); + return false; + } + } + window.GOVUK.sessionDialog.bindUIElements(); + window.GOVUK.sessionDialog.controller(); + return true; + } + return false; + } +}; +window.GOVUK.sessionDialog.init(); diff --git a/frontend/themes/gov-uk/styles/_session-timeout-dialog.scss b/frontend/themes/gov-uk/styles/_session-timeout-dialog.scss new file mode 100644 index 00000000..1c2ce15c --- /dev/null +++ b/frontend/themes/gov-uk/styles/_session-timeout-dialog.scss @@ -0,0 +1,121 @@ +.modal-dialog { + display: none; +} + +.modal-dialog--no-js-persistent { + display: inline-block; +} + +.js-enabled { + .modal-dialog--no-js-persistent { + display: none; + + &[open], .dialog-button { + display: inline-block; + } + } + + .modal-dialog[open] { + display: inline-block; + } +} + +.modal-dialog[open] { + overflow-x: hidden; + overflow-y: auto; + max-height: 90vh; + padding: 0; + margin-bottom: 0; + margin-top: 0; + position: fixed; + top: 50% !important; + -webkit-transform: translate(0, -50%); + -ms-transform: translate(0, -50%); + transform: translate(0, -50%); +} + +@media (min-width: 641px) { + .modal-dialog[open] { + padding: 0; + } +} + +.modal-dialog__inner { + padding: 20px; +} + +.modal-dialog #dialog-title { + margin-top: 0; +} + +.dialog-is-open { + overflow: hidden; +} + +dialog { + left: 0; + right: 0; + width: -moz-fit-content; + width: -webkit-fit-content; + width: fit-content; + height: -moz-fit-content; + height: -webkit-fit-content; + height: fit-content; + margin: auto; + border: solid; + padding: 1em; + background: white; + color: black; + display: none; + border: 5px solid black; + + + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: rgba(0, 0, 0, 0.8); + } + + &[open] { + display: block; + box-sizing: border-box; + margin: 0 auto; + padding: 15px; + width: 90%; + + + .backdrop, &::backdrop { + background: rgba(0, 0, 0, 0.8); + } + } +} + +@media (min-width: 641px) { + dialog[open] { + padding: 30px; + margin: 30px auto; + max-width: 500px; + } +} + +.js-enabled .govuk-timeout-warning-fallback { + display: none; +} + +.tabular-numbers { + font-family: "ntatabularnumbers", "nta", Arial, sans-serif; +} + +.dialog-exit-link { + @include govuk-font($size: 24); + display: inline-block; + + @include govuk-media-query($from: tablet) { + @include govuk-font($size: 19); + + position: relative; + padding: 0.526315em 0.789473em 0.263157em; + line-height: 1.5; + } +} diff --git a/frontend/themes/gov-uk/styles/govuk.scss b/frontend/themes/gov-uk/styles/govuk.scss index 05f3614e..9078448b 100644 --- a/frontend/themes/gov-uk/styles/govuk.scss +++ b/frontend/themes/gov-uk/styles/govuk.scss @@ -26,6 +26,7 @@ $path: "/public/images/" !default; @import "cookie-settings"; @import "check_your_answers"; @import "pdf"; +@import "session-timeout-dialog"; // Modules @import "modules/validation"; diff --git a/index.js b/index.js index bd100132..ae25be7b 100644 --- a/index.js +++ b/index.js @@ -85,6 +85,7 @@ const getContentSecurityPolicy = (config, res) => { 'www.google.co.uk/ads/ga-audiences' ], connectSrc: [ + "'self'", 'https://www.google-analytics.com', 'https://region1.google-analytics.com', 'https://region1.analytics.google.com' @@ -148,6 +149,11 @@ function bootstrap(options) { res.locals.appName = config.appName; res.locals.htmlLang = config.htmlLang; res.locals.cookieName = config.session.name; + // session timeout warning configs + res.locals.sessionTimeOut = config.session.ttl; + res.locals.sessionTimeOutWarning = config.sessionTimeOutWarning; + res.locals.sessionTimeoutWarningContent = config.sessionTimeoutWarningContent; + res.locals.exitFormContent = config.exitFormContent; next(); }); @@ -190,10 +196,10 @@ function bootstrap(options) { app.use(morgan('sessionId=:id ' + morgan.combined, { stream: config.logger.stream, skip: (req, res) => config.loglevel !== 'debug' && - ( - res.statusCode >= 300 || !_.get(req, 'session.id') || - config.ignoreMiddlewareLogs.some(v => req.originalUrl.includes(v)) - ) + ( + res.statusCode >= 300 || !_.get(req, 'session.id') || + config.ignoreMiddlewareLogs.some(v => req.originalUrl.includes(v)) + ) })); serveStatic(app, config); diff --git a/package.json b/package.json index d23a2006..2d361d76 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hof", "description": "A bootstrap for HOF projects", - "version": "21.1.3", + "version": "22.0.0", "license": "MIT", "main": "index.js", "author": "HomeOffice", @@ -21,13 +21,13 @@ "url": "https://github.com/UKHomeOfficeForms/hof/issues" }, "scripts": { - "test": "yarn run unit && yarn run test:cookie-banner && yarn run test:functional && yarn run test:client && yarn run test:lint", + "test": "yarn run unit && yarn run test:jest && yarn run test:functional && yarn run test:client && yarn run test:lint", "unit": "LOG_LEVEL=error nyc _mocha \"test/**/*.spec.js\" \"sandbox/test/**/*.spec.js\"", "unit:nocov": "LOG_LEVEL=error mocha \"test/**/*.spec.js\" \"sandbox/test/**/*.spec.js\"", "test:lint": "eslint . --config ./node_modules/eslint-config-hof/default.js", "test:functional": "funkie mocha ./test/functional-tests --timeout 20000 --exit", "test:client": "karma start test/frontend/toolkit/karma.conf.js", - "test:cookie-banner": "jest test/frontend/jest", + "test:jest": "jest test/frontend/jest", "test:acceptance": "TAGS=\"${TAGS:=@feature}\" yarn run test:cucumber", "test:acceptance_browser": "ACCEPTANCE_WITH_BROWSER=true TAGS=\"${TAGS:=@feature}\" yarn run test:cucumber", "test:cucumber": "cucumber-js -f @cucumber/pretty-formatter \"sandbox/test/_features/**/*.feature\" --require sandbox/test/_features/test.setup.js --require \"sandbox/test/_features/step_definitions/**/*.js\" --tags $TAGS", @@ -49,6 +49,7 @@ "csrf": "^3.1.0", "debug": "^4.3.1", "deprecate": "^1.0.0", + "dialog-polyfill": "^0.5.6", "dotenv": "^4.0.0", "duplexify": "^3.5.0", "express": "^4.17.1", diff --git a/sandbox/apps/sandbox/index.js b/sandbox/apps/sandbox/index.js index e6929da2..8de53d06 100644 --- a/sandbox/apps/sandbox/index.js +++ b/sandbox/apps/sandbox/index.js @@ -91,5 +91,7 @@ module.exports = { ], next: '/confirm' }, + '/session-timeout': {}, + '/exit': {} } }; diff --git a/sandbox/apps/sandbox/translations/src/en/exit.json b/sandbox/apps/sandbox/translations/src/en/exit.json new file mode 100644 index 00000000..626c156c --- /dev/null +++ b/sandbox/apps/sandbox/translations/src/en/exit.json @@ -0,0 +1,4 @@ +{ + "header": "You have left this form", + "title": "You have left this form" +} diff --git a/sandbox/apps/sandbox/translations/src/en/pages.json b/sandbox/apps/sandbox/translations/src/en/pages.json index 0c3f26f2..12f59c9b 100644 --- a/sandbox/apps/sandbox/translations/src/en/pages.json +++ b/sandbox/apps/sandbox/translations/src/en/pages.json @@ -50,8 +50,17 @@ }, "confirmation": { "title": "Application sent", - "alert": "Application sent", + "alert": "Application sent", "subheader": "What happens next", "content": "We’ll contact you with the decision of your application or if we need more information from you." + }, + "exit": { + "message": "We have cleared your information to keep it secure. Your information has not been saved." + }, + "session-timeout-warning": { + "dialog-title": "Your application will close soon", + "dialog-text": "If that happens, your progress will not be saved.", + "timeout-continue-button": "Stay on this page", + "dialog-exit-link": "Exit this form" } } diff --git a/sandbox/server.js b/sandbox/server.js index 24d07b98..7edfbe78 100644 --- a/sandbox/server.js +++ b/sandbox/server.js @@ -8,11 +8,16 @@ bootstrap({ routes: [ require('./apps/sandbox') ], + behaviours: [ + require('../').components.sessionTimeoutWarning + ], rateLimits: { requests: { active: true } }, getAccessibility: true, + sessionTimeoutWarningContent: true, + exitFormContent: true, "port": 8082 }); diff --git a/test/components/session-timeout-warning.spec.js b/test/components/session-timeout-warning.spec.js new file mode 100644 index 00000000..5a16f464 --- /dev/null +++ b/test/components/session-timeout-warning.spec.js @@ -0,0 +1,120 @@ +'use strict'; + +const { sessionTimeoutWarning: Component } = require('../../components'); +const config = require('../../config/hof-defaults'); + +describe('session timeout warning component', () => { + class Base { + configure() { } + locals() { } + } + + let req; + let res; + let instance; + let next; + let resetStub; + + beforeEach(() => { + req = hof_request(); + res = reqres.res(); + resetStub = sinon.stub(); + req.sessionModel.reset = resetStub; + next = sinon.stub(); + }); + + describe("The 'configure' method ", () => { + beforeEach(() => { + sinon.stub(Base.prototype, 'configure').returns(req, res, next); + instance = new (Component(Base))(); + }); + + it('resets the session if on the exit page', () => { + req.form = { + options: { + route: '/exit' + } + }; + instance.configure(req, res, next); + resetStub.should.have.been.calledOnce; + }); + + it('does not reset the session if not on the exit page', () => { + req.form = { + options: { + route: '/name' + } + }; + instance.configure(req, res, next); + resetStub.should.not.have.been.called; + }); + + + afterEach(() => { + sinon.restore(); + }); + }); + + describe("The 'locals' method ", () => { + beforeEach(() => { + sinon.stub(Base.prototype, 'locals').returns(req, res); + instance = new (Component(Base))(); + }); + + it('sets the default dialog content to true if locals.sessionTimeoutWarningContent is set to true', () => { + res.locals = { + sessionTimeoutWarningContent: true + }; + const locals = instance.locals(req, res); + const checkDialogProperties = expected => { + locals.should.have.property('dialogTitle').and.deep.equal(expected); + locals.should.have.property('dialogText').and.deep.equal(expected); + locals.should.have.property('timeoutContinueButton').and.deep.equal(expected); + locals.should.have.property('dialogExitLink').and.deep.equal(expected); + }; + checkDialogProperties(true); + }); + + it('does not set the dialog content to true if locals.sessionTimeoutWarningContent is set to false', () => { + res.locals = { + sessionTimeoutWarningContent: false + }; + const locals = instance.locals(req, res); + const checkDialogProperties = () => { + locals.should.not.have.property('dialogTitle'); + locals.should.not.have.property('dialogText'); + locals.should.not.have.property('timeoutContinueButton'); + locals.should.not.have.property('dialogExitLink'); + }; + checkDialogProperties(); + }); + + it('sets the custom content to true on the exit page if exitFormContent is set to true', () => { + config.exitFormContent = true; + req.form = { + options: { + route: '/exit' + } + }; + const locals = instance.locals(req, res); + locals.should.have.property('exitFormContent').and.deep.equal(true); + }); + + it('does sets the default content on the exit page if exitFormContent is set to false', () => { + config.exitFormContent = false; + req.form = { + options: { + route: '/exit' + } + }; + const locals = instance.locals(req, res); + locals.should.have.property('header').and.deep.equal('exit.header'); + locals.should.have.property('title').and.deep.equal('exit.title'); + locals.should.have.property('message').and.deep.equal('exit.message'); + }); + + afterEach(() => { + Base.prototype.locals.restore(); + }); + }); +}); diff --git a/test/frontend/jest/sessionDialog.test.js b/test/frontend/jest/sessionDialog.test.js new file mode 100644 index 00000000..1a980b25 --- /dev/null +++ b/test/frontend/jest/sessionDialog.test.js @@ -0,0 +1,163 @@ +/* eslint-disable max-len, quotes */ +'use strict'; + +const $ = require('jquery'); +const fs = require('fs'); +const path = require('path'); +const sessionTimeoutWarningHtml = fs.readFileSync(path.resolve(__dirname, '../../../frontend/template-partials/views/partials/session-timeout-warning.html'), 'utf8'); + +jest.dontMock('fs'); + +describe('sessionDialog', () => { + let sessionDialog; + let $body; + let $html; + let options; + let originalHTMLDialogElement; + + beforeAll(() => { + // Set up the initial DOM structure needed for the tests + document.body.innerHTML = `
` + sessionTimeoutWarningHtml.toString(); + window.GOVUK = {}; + require('../../../frontend/themes/gov-uk/client-js/session-timeout-dialog.js'); + sessionDialog = window.GOVUK.sessionDialog; + $html = $('html'); + $body = $('body'); + options = { + secondsSessionTimeout: 1800, + secondsTimeoutWarning: 300 + }; + // Mock the showModal and close methods of the element + sessionDialog.el.showModal = jest.fn(); + sessionDialog.el.close = jest.fn(); + sessionDialog.$fallBackElement = { classList: { add: jest.fn() } }; + window.dialogPolyfill = { registerDialog: jest.fn() }; + originalHTMLDialogElement = HTMLDialogElement; + }); + + afterEach(() => { + if (originalHTMLDialogElement !== undefined) { + Object.defineProperty(window, 'HTMLDialogElement', { + value: originalHTMLDialogElement, + configurable: true + }); + } else { + delete window.HTMLDialogElement; + } + jest.clearAllMocks(); + $html.removeClass(sessionDialog.dialogIsOpenClass); + $body.removeClass(sessionDialog.dialogIsOpenClass); + }); + + it('should initialize correctly', () => { + const controller = jest.spyOn(sessionDialog, 'controller'); + sessionDialog.init(options); + expect(sessionDialog.secondsSessionTimeout).toBe(1800); + expect(sessionDialog.secondsTimeoutWarning).toBe(300); + expect(sessionDialog.timeoutRedirectUrl).toBe('/session-timeout'); + expect(controller).toHaveBeenCalled(); + }); + + it('should bind UI elements', () => { + const spyOnBindUIElements = jest.spyOn(sessionDialog, 'bindUIElements'); + sessionDialog.init(); + expect(spyOnBindUIElements).toHaveBeenCalled(); + }); + + it('should open the dialog', () => { + sessionDialog.openDialog(); + expect($html.hasClass(sessionDialog.dialogIsOpenClass)).toBe(true); + expect($body.hasClass(sessionDialog.dialogIsOpenClass)).toBe(true); + expect(sessionDialog.el.showModal).toHaveBeenCalled(); + }); + + it('should close the dialog', () => { + sessionDialog.openDialog(); + expect(sessionDialog.isDialogOpen()).toBe(true); + sessionDialog.closeDialog(); + expect($html.hasClass(sessionDialog.dialogIsOpenClass)).toBe(false); + expect($body.hasClass(sessionDialog.dialogIsOpenClass)).toBe(false); + expect(sessionDialog.isDialogOpen()).toBe(false); + expect(sessionDialog.el.close).toHaveBeenCalled(); + }); + + it('should save and restore last focused element', () => { + const button = document.querySelector('.js-dialog-close'); + button.focus(); + sessionDialog.saveLastFocusedEl(); + sessionDialog.openDialog(); + sessionDialog.closeDialog(); + expect(document.activeElement).toBe(button); + }); + + it('should make page content inert and remove inert', () => { + const content = document.querySelector('#content'); + sessionDialog.makePageContentInert(); + expect(content.inert).toBe(true); + expect(content.getAttribute('aria-hidden')).toBe('true'); + sessionDialog.removeInertFromPageContent(); + expect(content.inert).toBe(false); + expect(content.getAttribute('aria-hidden')).toBe('false'); + }); + + it('should check if dialog is configured', () => { + expect(sessionDialog.isConfigured()).toBeTruthy(); + }); + + it('should redirect when session times out', () => { + const spyOnRedirect = jest.spyOn(sessionDialog, 'redirect'); + jest.spyOn(sessionDialog, 'secondsUntilSessionTimeout').mockReturnValue(0); + sessionDialog.controller(); + expect(spyOnRedirect).toHaveBeenCalled(); + }); + + it('should show warning and start countdown', () => { + const spyOnOpenDialog = jest.spyOn(sessionDialog, 'openDialog'); + const spyOnStartCountdown = jest.spyOn(sessionDialog, 'startCountdown'); + jest.spyOn(sessionDialog, 'secondsUntilSessionTimeout').mockReturnValue(sessionDialog.secondsTimeoutWarning - 1); + sessionDialog.controller(); + expect(spyOnOpenDialog).toHaveBeenCalled(); + expect(spyOnStartCountdown).toHaveBeenCalled(); + }); + + it('should wait for warning when enough time is left', () => { + const spyOnAddTimer = jest.spyOn(sessionDialog, 'addTimer'); + jest.spyOn(sessionDialog, 'secondsUntilSessionTimeout').mockReturnValue(300); + sessionDialog.controller(); + expect(spyOnAddTimer).toHaveBeenCalledWith(sessionDialog.controller, sessionDialog.secondsTimeoutWarning); + }); + + it('should use polyfill if HTMLDialogElement is not a function and polyfill registration succeeds', () => { + Object.defineProperty(window, 'HTMLDialogElement', { value: undefined, configurable: true }); + window.dialogPolyfill.registerDialog.mockImplementation(() => true); + const result = sessionDialog.init(options); + expect(window.dialogPolyfill.registerDialog).toHaveBeenCalledWith(window.GOVUK.sessionDialog.el); + expect(result).toBe(true); + }); + + it('should display fallback element if polyfill registration fails', () => { + Object.defineProperty(window, 'HTMLDialogElement', { value: undefined, configurable: true }); + window.dialogPolyfill.registerDialog.mockImplementation(() => { + throw new Error('polyfill error'); + }); + const result = sessionDialog.init(options); + expect(sessionDialog.$fallBackElement.classList.add).toHaveBeenCalledWith('govuk-!-display-block'); + expect(result).toBe(false); + }); + + it('should bind UI elements and call controller if HTMLDialogElement is a function', () => { + Object.defineProperty(window, 'HTMLDialogElement', { value: function () { }, configurable: true }); + const result = sessionDialog.init(options); + expect(sessionDialog.bindUIElements).toHaveBeenCalled(); + expect(sessionDialog.controller).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false if sessionDialog is not configured', () => { + jest.spyOn(sessionDialog, 'isConfigured').mockReturnValue(false); + const result = sessionDialog.init(options); + expect(sessionDialog.bindUIElements).not.toHaveBeenCalled(); + expect(sessionDialog.controller).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index d9196d93..557551ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3509,6 +3509,11 @@ di@^0.0.1: resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== +dialog-polyfill@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a" + integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w== + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1"