diff --git a/app/javascript/packs/session-expire-session.ts b/app/javascript/packs/session-expire-session.ts index e92090085cd..fd94090aeb3 100644 --- a/app/javascript/packs/session-expire-session.ts +++ b/app/javascript/packs/session-expire-session.ts @@ -1,10 +1,40 @@ -const expireConfig = document.getElementById('js-expire-session'); +import { extendSession } from '@18f/identity-session'; +import type { CountdownElement } from '@18f/identity-countdown/countdown-element'; +import type { ModalElement } from '@18f/identity-modal'; -if (expireConfig && expireConfig.dataset.sessionTimeoutIn) { - const sessionTimeoutIn = parseInt(expireConfig.dataset.sessionTimeoutIn, 10) * 1000; - const timeoutRefreshPath = expireConfig.dataset.timeoutRefreshPath || ''; +const warningEl = document.getElementById('session-timeout-cntnr')!; - setTimeout(() => { +const warning = Number(warningEl.dataset.warning!) * 1000; +const sessionsURL = warningEl.dataset.sessionsUrl!; +const sessionTimeout = Number(warningEl.dataset.sessionTimeoutIn!) * 1000; +const modal = document.querySelector('lg-modal.session-timeout-modal')!; +const keepaliveEl = document.getElementById('session-keepalive-btn'); +const countdownEls: NodeListOf = modal.querySelectorAll('lg-countdown'); +const timeoutRefreshPath = warningEl.dataset.timeoutRefreshPath || ''; + +let sessionExpiration = new Date(Date.now() + sessionTimeout); + +function showModal() { + modal.show(); + countdownEls.forEach((countdownEl) => { + countdownEl.expiration = sessionExpiration; + countdownEl.start(); + }); +} + +function keepalive() { + const isExpired = new Date() > sessionExpiration; + if (isExpired) { document.location.href = timeoutRefreshPath; - }, sessionTimeoutIn); + } else { + modal.hide(); + sessionExpiration = new Date(Date.now() + sessionTimeout); + + setTimeout(showModal, sessionTimeout - warning); + countdownEls.forEach((countdownEl) => countdownEl.stop()); + extendSession(sessionsURL); + } } + +keepaliveEl?.addEventListener('click', keepalive, false); +setTimeout(showModal, sessionTimeout - warning); diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9ca2248b6c8..95c76f2f692 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -30,6 +30,8 @@ <%= render partial: 'session_timeout/expire_session', locals: { session_timeout_in: Devise.timeout_in, + warning: session_timeout_warning, + modal: session_modal, } %> <% end %> diff --git a/app/views/session_timeout/_expire_session.html.erb b/app/views/session_timeout/_expire_session.html.erb index 90dcee3ca62..b68f9ab6972 100644 --- a/app/views/session_timeout/_expire_session.html.erb +++ b/app/views/session_timeout/_expire_session.html.erb @@ -1,7 +1,8 @@ -<%= tag.div id: 'js-expire-session', +<%= tag.div id: 'session-timeout-cntnr', data: { session_timeout_in: session_timeout_in, - timeout_refresh_path: timeout_refresh_path, + warning: warning, + sessions_url: api_internal_sessions_path, } %> - +<%= render(partial: 'session_timeout/warning', locals: { modal_presenter: modal }) %> <%= javascript_packs_tag_once 'session-expire-session', preload_links_header: false %> diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 15d4e3abe6d..4228e237c53 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -241,6 +241,8 @@ allow(IdentityConfig.store).to receive(:session_check_delay).and_return(0) allow(IdentityConfig.store).to receive(:session_timeout_warning_seconds) .and_return(Devise.timeout_in) + allow(Devise).to receive(:timeout_in) + .and_return(IdentityConfig.store.session_timeout_warning_seconds + 1) user = create(:user, :fully_registered) sign_in_user(user) @@ -252,22 +254,6 @@ end end - context 'signed out' do - it 'refreshes the current page after session expires', js: true do - allow(Devise).to receive(:timeout_in).and_return(1) - - visit sign_up_email_path(request_id: '123abc') - fill_in t('forms.registration.labels.email'), with: 'test@example.com' - - expect(page).to have_content( - t('notices.session_cleared', minutes: IdentityConfig.store.session_timeout_in_minutes), - wait: 5, - ) - expect(page).to have_field(t('forms.registration.labels.email'), with: '') - expect(current_url).to match Regexp.escape(sign_up_email_path(request_id: '123abc')) - end - end - context 'signing back in after session timeout length' do around do |example| with_forgery_protection { example.run } @@ -296,19 +282,49 @@ end end - it 'refreshes the page (which clears the form) and notifies the user', js: true do - allow(Devise).to receive(:timeout_in).and_return(1) - user = create(:user) - visit root_path - fill_in t('account.index.email'), with: user.email - fill_in 'Password', with: user.password + context 'create account' do + it 'shows the timeout modal when the session expiration approaches', js: true do + allow(Devise).to receive(:timeout_in) + .and_return(IdentityConfig.store.session_timeout_warning_seconds + 1) - expect(page).to have_content( - t('notices.session_cleared', minutes: IdentityConfig.store.session_timeout_in_minutes), - wait: 5, - ) - expect(find_field('Email').value).to be_blank - expect(find_field('Password').value).to be_blank + visit sign_up_email_path + fill_in t('forms.registration.labels.email'), with: 'test@example.com' + + expect(page).to have_css('.usa-js-modal--active', wait: 10) + + click_button t('notices.timeout_warning.partially_signed_in.continue') + + expect(page).not_to have_css('.usa-js-modal--active') + expect(find_field(t('forms.registration.labels.email')).value).not_to be_blank + end + end + + context 'sign in' do + it 'shows the timeout modal when the session expiration approaches', js: true do + allow(Devise).to receive(:timeout_in) + .and_return(IdentityConfig.store.session_timeout_warning_seconds + 1) + + visit root_path + fill_in t('account.index.email'), with: 'test@example.com' + + expect(page).to have_css('.usa-js-modal--active', wait: 10) + + click_button t('notices.timeout_warning.partially_signed_in.continue') + expect(find_field(t('account.index.email')).value).not_to be_blank + end + + it 'reloads the sign in page when cancel is clicked', js: true do + allow(Devise).to receive(:timeout_in) + .and_return(IdentityConfig.store.session_timeout_warning_seconds + 1) + + visit root_path + fill_in t('account.index.email'), with: 'test@example.com' + + expect(page).to have_css('.usa-js-modal--active', wait: 10) + + click_button t('notices.timeout_warning.partially_signed_in.sign_out') + expect(find_field(t('account.index.email')).value).to be_blank + end end end diff --git a/spec/views/layouts/application.html.erb_spec.rb b/spec/views/layouts/application.html.erb_spec.rb index 0fcedb4c83e..c7565dccf21 100644 --- a/spec/views/layouts/application.html.erb_spec.rb +++ b/spec/views/layouts/application.html.erb_spec.rb @@ -15,6 +15,7 @@ ).create_session, ) allow(view.request).to receive(:original_fullpath).and_return('/foobar') + allow(view).to receive(:user_fully_authenticated?).and_return(false) view.title = title_content if title_content end @@ -123,7 +124,6 @@ context 'session expiration' do it 'renders a javascript page refresh' do - allow(view).to receive(:user_fully_authenticated?).and_return(false) allow(view).to receive(:current_user).and_return(false) allow(view).to receive(:decorated_sp_session).and_return(NullServiceProviderSession.new) render