diff --git a/app/assets/javascripts/app/utils/auto-logout.js b/app/assets/javascripts/app/utils/auto-logout.js new file mode 100644 index 00000000000..f15d0245f59 --- /dev/null +++ b/app/assets/javascripts/app/utils/auto-logout.js @@ -0,0 +1,5 @@ +export default () => { + window.onbeforeunload = null; + window.onunload = null; + window.location.href = '/timeout'; +}; diff --git a/app/assets/javascripts/app/utils/countdown-timer.js b/app/assets/javascripts/app/utils/countdown-timer.js new file mode 100644 index 00000000000..4115973b7b2 --- /dev/null +++ b/app/assets/javascripts/app/utils/countdown-timer.js @@ -0,0 +1,21 @@ +import msFormatter from './ms-formatter'; +import autoLogout from './auto-logout'; + +export default (targetSelector, timeLeft = 0, interval = 1000) => { + const countdownTarget = document.querySelector(targetSelector); + let remaining = timeLeft; + + if (!countdownTarget) return; + + (function tick() { + countdownTarget.innerHTML = msFormatter(remaining); + + if (remaining <= 0) { + autoLogout(); + return; + } + + remaining -= interval; + setTimeout(tick, interval); + }()); +}; diff --git a/app/assets/javascripts/app/utils/index.js b/app/assets/javascripts/app/utils/index.js new file mode 100644 index 00000000000..aa886d21029 --- /dev/null +++ b/app/assets/javascripts/app/utils/index.js @@ -0,0 +1,9 @@ +import autoLogout from './auto-logout'; +import countdownTimer from './countdown-timer'; +import msFormatter from './ms-formatter'; + +const LoginGov = window.LoginGov = (window.LoginGov || {}); + +LoginGov.autoLogout = autoLogout; +LoginGov.countdownTimer = countdownTimer; +LoginGov.msFormatter = msFormatter; diff --git a/app/assets/javascripts/app/utils/ms-formatter.js b/app/assets/javascripts/app/utils/ms-formatter.js new file mode 100644 index 00000000000..4d789bdc99c --- /dev/null +++ b/app/assets/javascripts/app/utils/ms-formatter.js @@ -0,0 +1,24 @@ +function pluralize(word, count) { + return `${word}${count !== 1 ? 's' : ''}`; +} + +function formatMinutes(minutes) { + if (!minutes) return 0; + + return `${minutes} ${pluralize('minute', minutes)}`; +} + +function formatSeconds(seconds) { + return `${seconds} ${pluralize('second', seconds)}`; +} + +export default (milliseconds) => { + const seconds = milliseconds / 1000; + const minutes = parseInt(seconds / 60, 10); + const remainingSeconds = parseInt(seconds % 60, 10); + + const displayMinutes = formatMinutes(minutes); + const displaySeconds = formatSeconds(remainingSeconds); + + return `${displayMinutes} and ${displaySeconds}`; +}; diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9ee510b2cdb..81568a1eb89 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,3 +1,4 @@ +import 'app/utils/index'; import 'app/pw-toggle'; import 'app/form-validation'; import 'app/form-field-format'; diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index d92096b27bc..9ad59998839 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -50,7 +50,7 @@ html lang="#{I18n.locale}" = render 'shared/footer_lite' #session-timeout-cntnr - - if current_user + - if user_fully_authenticated? = auto_session_timeout_js -else = auto_session_expired_js diff --git a/app/views/session_timeout/_ping.js.erb b/app/views/session_timeout/_ping.js.erb index c5d5d9c3bbe..2dc6d6ef719 100644 --- a/app/views/session_timeout/_ping.js.erb +++ b/app/views/session_timeout/_ping.js.erb @@ -28,41 +28,13 @@ function success(data) { if (show_warning & !el) { cntnr.insertAdjacentHTML('afterbegin', warning_info); - initTimer(warning); + window.LoginGov.countdownTimer('#countdown', warning); } - if (!show_warning & el) el.remove(); - if (data.live == false) { - window.onbeforeunload = null; - window.onunload = null; - window.location.href = '/timeout'; - } -} - -function initTimer(duration) { - var countdown = document.getElementById('countdown'); - var timeLeft = duration; - var interval = 1000; - - var format = function(milliseconds) { - var seconds = milliseconds / 1000; - var minutes = parseInt(seconds / 60, 10); - var remainingSeconds = parseInt(seconds % 60, 10); - var displayMinutes = minutes == 0 ? '' : - minutes + ' minute' + (minutes !== 1 ? 's' : '') + ' and '; - var displaySeconds = remainingSeconds + ' second' + (remainingSeconds !== 1 ? 's' : ''); - - return (displayMinutes + displaySeconds); - }; - - function tick() { - countdown.innerHTML = format(timeLeft); - if (timeLeft <= 0) return; - timeLeft -= interval; - setTimeout(tick, interval); + if (!show_warning & el) el.remove(); + if (!data.live) { + window.LoginGov.autoLogout(); } - - tick(); } setTimeout(ping, start); diff --git a/app/views/session_timeout/_warning.html.slim b/app/views/session_timeout/_warning.html.slim index 260a73b28bf..ef1d4989d16 100644 --- a/app/views/session_timeout/_warning.html.slim +++ b/app/views/session_timeout/_warning.html.slim @@ -4,7 +4,8 @@ .mx-auto.p4.cntnr-skinny.border-box.bg-white.rounded.relative = image_tag(asset_url('clock.svg'), class: 'modal-ico') h3.mt0.mb2 = t('headings.session_timeout_warning') - p.mb3 == t('session_timeout_warning', time_left_in_session: time_left_in_session) + p.mb3 == t('session_timeout_warning', + time_left_in_session: content_tag(:span, time_left_in_session, id: 'countdown')) = link_to t('forms.buttons.continue_browsing'), request.original_url, class: 'btn btn-primary' = link_to t('forms.buttons.sign_out'), diff --git a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb b/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb new file mode 100644 index 00000000000..aa07fab8168 --- /dev/null +++ b/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb @@ -0,0 +1,18 @@ +<% lockout_time_in_words = decorated_user.lockout_time_remaining_in_words %> +<% title t('titles.account_locked') %> + +

+ <%= t('titles.account_locked') %> +

+

+ <%= t('devise.two_factor_authentication.max_login_attempts_reached') %> +

+

+ <%= t('devise.two_factor_authentication.please_try_again_html', + time_remaining: content_tag(:span, lockout_time_in_words, id: 'countdown')) %> +

+ +<%= nonced_javascript_tag do %> + var test = <%= decorated_user.lockout_time_remaining %> * 1000; + window.LoginGov.countdownTimer('#countdown', test); +<% end %> diff --git a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim b/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim deleted file mode 100644 index 86ae0803f8b..00000000000 --- a/app/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim +++ /dev/null @@ -1,7 +0,0 @@ -- title t('titles.account_locked') - - -h1.h3.my0 = t('titles.account_locked') -p = t('devise.two_factor_authentication.max_login_attempts_reached') -p = t('devise.two_factor_authentication.please_try_again', - time_remaining: decorated_user.lockout_time_remaining_in_words) diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml index d04b6f642c1..551dbd648e3 100644 --- a/config/locales/devise/en.yml +++ b/config/locales/devise/en.yml @@ -116,7 +116,7 @@ en: max_login_attempts_reached: > Your account is temporarily locked because you have entered the one-time passcode incorrectly too many times. - please_try_again: Please try again in %{time_remaining}. + please_try_again_html: Please try again in %{time_remaining}. recovery_code_header_text: Enter your recovery code recovery_code_prompt: > You can use this recovery code once. If you still need a code after signing in, go to your diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 92a4bd91080..3d6ad3f21b8 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -38,4 +38,4 @@ en: session_timedout: > We signed you out due to inactivity. This helps keep your account safe. Please sign in again. session_timeout_warning: >- - Your session will end in %{time_left_in_session} due to inactivity. + Your session will end in %{time_left_in_session} due to inactivity. diff --git a/spec/views/layouts/application.html.slim_spec.rb b/spec/views/layouts/application.html.slim_spec.rb index 881600dd116..3805b5244fb 100644 --- a/spec/views/layouts/application.html.slim_spec.rb +++ b/spec/views/layouts/application.html.slim_spec.rb @@ -3,6 +3,10 @@ describe 'layouts/application.html.slim' do include Devise::Test::ControllerHelpers + before do + allow(view).to receive(:user_fully_authenticated?).and_return(true) + end + context 'when i18n mode enabled' do before do allow(FeatureManagement).to receive(:enable_i18n_mode?).and_return(true) diff --git a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim_spec.rb b/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb similarity index 86% rename from spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim_spec.rb rename to spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb index e729d65f541..dd3e4e8d6ab 100644 --- a/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.slim_spec.rb +++ b/spec/views/two_factor_authentication/shared/max_login_attempts_reached.html.erb_spec.rb @@ -1,11 +1,12 @@ require 'rails_helper' -describe 'two_factor_authentication/shared/max_login_attempts_reached.html.slim' do +describe 'two_factor_authentication/shared/max_login_attempts_reached.html.erb' do context 'locked out account' do it 'includes localized error message with time remaining' do user_decorator = instance_double(UserDecorator) allow(view).to receive(:decorated_user).and_return(user_decorator) allow(user_decorator).to receive(:lockout_time_remaining_in_words).and_return('1000 years') + allow(user_decorator).to receive(:lockout_time_remaining).and_return(10_000) render