diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index a13d94d..e8e9e4f 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < Administrate::ApplicationController before_action :authenticate_user! include NavigationData + include SessionTimeoutData # Override this value to specify the number of elements to display at a time # on index pages. Defaults to 20. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c647a94..09bfb0a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,4 +4,5 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! include NavigationData + include SessionTimeoutData end diff --git a/app/controllers/concerns/session_timeout_data.rb b/app/controllers/concerns/session_timeout_data.rb new file mode 100644 index 0000000..fb00173 --- /dev/null +++ b/app/controllers/concerns/session_timeout_data.rb @@ -0,0 +1,35 @@ +module SessionTimeoutData + extend ActiveSupport::Concern + + included do + helper_method :session_timeout_data if respond_to?(:helper_method, true) + end + + private + + def session_timeout_data + data = { controller: "session-timeout" } + return data unless user_signed_in? + + timeout_in_seconds = Devise.timeout_in.to_i + session_data = request.env.fetch("warden", nil)&.session(:user) || {} + last_request_at = session_data && session_data["last_request_at"] + + expires_at = if last_request_at.present? + last_request_at.to_i + timeout_in_seconds + else + Time.current.to_i + timeout_in_seconds + end + + data.merge( + session_timeout_expires_at_value: expires_at, + session_timeout_duration_value: timeout_in_seconds, + session_timeout_warning_offset_value: Rails.application.config.session_management.warning_lead_time.to_i, + session_timeout_keepalive_url_value: user_session_keepalive_path, + session_timeout_warning_message_value: t("session_timeout.warning_message"), + session_timeout_stay_signed_in_label_value: t("session_timeout.stay_signed_in"), + session_timeout_error_message_value: t("session_timeout.error_message"), + session_timeout_expired_message_value: t("session_timeout.expired_message") + ) + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 0000000..933267f --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,24 @@ +module Users + # Handles session-related customizations for Devise + class SessionsController < Devise::SessionsController + before_action :authenticate_user!, only: :keepalive + skip_before_action :require_no_authentication, only: :new + before_action :redirect_signed_in_users, only: :new + + def keepalive + expires_at = Time.current.to_i + Devise.timeout_in.to_i + render json: { expires_at: expires_at } + end + + private + + def redirect_signed_in_users + return unless user_signed_in? + + respond_to do |format| + format.html { redirect_to after_sign_in_path_for(current_user) } + format.any { head :no_content } + end + end + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 0539716..86a65c3 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,5 +1,6 @@ import "@hotwired/turbo-rails"; -import "./controllers"; +import { application } from "./controllers"; +window.Stimulus = application; import * as bootstrap from "bootstrap"; window.bootstrap = bootstrap; // Make Bootstrap globally available diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 5761f40..fc6f1d8 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -1,10 +1,12 @@ import { Application } from "@hotwired/stimulus"; import WunderbaumController from "./wunderbaum_controller"; import BatchActionsController from "./batch_actions_controller"; +import SessionTimeoutController from "./session_timeout_controller"; const application = Application.start(); application.register("wunderbaum", WunderbaumController); application.register("batch-actions", BatchActionsController); +application.register("session-timeout", SessionTimeoutController); -export { application } \ No newline at end of file +export { application } diff --git a/app/javascript/controllers/session_timeout_controller.js b/app/javascript/controllers/session_timeout_controller.js new file mode 100644 index 0000000..d341732 --- /dev/null +++ b/app/javascript/controllers/session_timeout_controller.js @@ -0,0 +1,223 @@ +import { Controller } from "@hotwired/stimulus"; + +// Manages Devise session timeout warnings and keepalive behavior. +export default class extends Controller { + static values = { + expiresAt: Number, + duration: Number, + warningOffset: Number, + keepaliveUrl: String, + warningMessage: String, + staySignedInLabel: String, + errorMessage: String, + expiredMessage: String, + }; + + connect() { + this.flashElement = this.element; + if (!this.flashElement) return; + this.requestInFlight = false; + this.cacheMenuElements(); + this.toggleMenus(this.hasExpiresAtValue); + + this.resetTimers(); + } + + disconnect() { + this.clearTimers(); + this.hideWarning(); + } + + resetTimers() { + this.clearTimers(); + + if (!this.hasExpiresAtValue || !this.hasWarningOffsetValue) return; + + const warningAt = (this.expiresAtValue - this.warningOffsetValue) * 1000; + const expirationAt = this.expiresAtValue * 1000; + const millisUntilWarning = warningAt - Date.now(); + const millisUntilExpiration = expirationAt - Date.now(); + + if (millisUntilWarning <= 0) { + this.showWarning(); + } else { + this.warningTimer = setTimeout(() => this.showWarning(), millisUntilWarning); + } + + if (millisUntilExpiration <= 0) { + this.handleExpiration(); + } else { + this.expirationTimer = setTimeout(() => this.handleExpiration(), millisUntilExpiration); + } + } + + resetSession(event) { + event.preventDefault(); + if (!this.hasKeepaliveUrlValue || this.requestInFlight) return; + this.requestInFlight = true; + + fetch(this.keepaliveUrlValue, { + method: "POST", + headers: { + "X-CSRF-Token": this.csrfToken, + "Accept": "application/json", + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then((response) => { + if (!response.ok) throw new Error("Keepalive request failed"); + return response.json().catch(() => ({})); + }) + .then((data) => { + if (data.expires_at) { + this.expiresAtValue = data.expires_at; + } else if (this.hasDurationValue) { + this.expiresAtValue = Math.floor(Date.now() / 1000) + this.durationValue; + } + this.hideWarning(); + this.resetTimers(); + this.toggleMenus(true); + }) + .catch(() => this.showError()) + .finally(() => { + this.requestInFlight = false; + }); + } + + showWarning() { + if (!this.flashElement || this.warningVisible) return; + + const remainingMinutes = this.minutesFromSeconds(this.warningOffsetValue); + const messageTemplate = this.valueOrDefault("warningMessage", "Your session will expire in %{minutes} minutes."); + const messageText = messageTemplate.replace("%{minutes}", remainingMinutes); + const staySignedInLabel = this.valueOrDefault("staySignedInLabel", "Stay signed in"); + + const alert = document.createElement("div"); + alert.className = "alert alert-warning alert-dismissible fade show mt-3"; + alert.setAttribute("role", "alert"); + alert.innerHTML = ` +
+ ${messageText} +
+ +
+
+ `; + + this.flashElement.prepend(alert); + this.warningElement = alert; + this.warningVisible = true; + } + + hideWarning() { + if (!this.warningVisible || !this.warningElement) return; + this.warningElement.remove(); + this.warningElement = null; + this.warningVisible = false; + } + + showError() { + if (!this.flashElement) return; + this.hideWarning(); + this.showAlert({ + level: "danger", + message: this.valueOrDefault("errorMessage", "We couldn't extend your session. Please save your work and sign in again."), + }); + } + + clearTimers() { + if (this.warningTimer) { + clearTimeout(this.warningTimer); + this.warningTimer = null; + } + if (this.expirationTimer) { + clearTimeout(this.expirationTimer); + this.expirationTimer = null; + } + } + + get csrfToken() { + const element = document.querySelector("meta[name='csrf-token']"); + return element && element.getAttribute("content"); + } + + handleExpiration() { + this.clearTimers(); + this.hideWarning(); + if (!this.flashElement) return; + this.toggleMenus(false); + + this.showAlert({ + level: "danger", + message: this.valueOrDefault("expiredMessage", "Your session has expired. Please sign in again to continue."), + trackWarning: true, + }); + } + + showAlert({ level, message, trackWarning = false }) { + const alert = document.createElement("div"); + alert.className = `alert alert-${level} alert-dismissible fade show mt-3`; + alert.setAttribute("role", "alert"); + alert.innerHTML = ` + ${message} + + `; + + this.flashElement.prepend(alert); + + if (trackWarning) { + this.warningElement = alert; + this.warningVisible = true; + } + } + + hideAlert(event) { + event.preventDefault(); + const alert = event.target.closest(".alert"); + if (!alert) return; + if (alert === this.warningElement) { + this.warningElement = null; + this.warningVisible = false; + } + alert.remove(); + } + + minutesFromSeconds(seconds) { + const minutes = Math.ceil(seconds / 60); + return Math.max(minutes, 1); + } + + valueOrDefault(name, fallback = "") { + const hasKey = this[`has${this.capitalize(name)}Value`]; + if (hasKey) { + return this[`${name}Value`]; + } + return fallback; + } + + capitalize(value) { + return value.charAt(0).toUpperCase() + value.slice(1); + } + + cacheMenuElements() { + this.signedInMenus = Array.from(document.querySelectorAll("[data-session-timeout-signed-in]")); + this.signedOutMenus = Array.from(document.querySelectorAll("[data-session-timeout-signed-out]")); + } + + toggleMenus(isSignedIn) { + if (!this.signedInMenus || !this.signedOutMenus) { + this.cacheMenuElements(); + } + + this.signedInMenus?.forEach((element) => this.setVisibility(element, isSignedIn)); + this.signedOutMenus?.forEach((element) => this.setVisibility(element, !isSignedIn)); + } + + setVisibility(element, shouldShow) { + if (!element) return; + element.classList.toggle("d-none", !shouldShow); + } +} diff --git a/app/views/layouts/administrate/application.html.erb b/app/views/layouts/administrate/application.html.erb index 079b696..0418327 100644 --- a/app/views/layouts/administrate/application.html.erb +++ b/app/views/layouts/administrate/application.html.erb @@ -21,7 +21,9 @@
- <%= render 'shared/flashes' %> + <%= content_tag :div, id: "flash-messages", data: session_timeout_data do %> + <%= render 'shared/flashes' %> + <% end %> <%= yield %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e480e9b..4992839 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,7 +23,9 @@ <%= render 'shared/global_navigation' %>
- <%= render 'shared/flashes' %> + <%= content_tag :div, id: "flash-messages", data: session_timeout_data do %> + <%= render 'shared/flashes' %> + <% end %> <%= yield %>
diff --git a/app/views/shared/_global_navigation.html.erb b/app/views/shared/_global_navigation.html.erb index 20739de..cc761b7 100644 --- a/app/views/shared/_global_navigation.html.erb +++ b/app/views/shared/_global_navigation.html.erb @@ -71,40 +71,38 @@ - <% if user_signed_in? %> - - <% else %> - - <% end %> + +
- <% if user_signed_in? %> +
- <% else %> +
+
<%= link_to "Sign In", new_user_session_path, class: "nav-link user-menu-link" %> - <% end %> +
diff --git a/config/application.rb b/config/application.rb index 15e5995..ef64cda 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,9 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + config.session_management = ActiveSupport::OrderedOptions.new + config.session_management.timeout = 8.hours + config.session_management.warning_lead_time = 5.minutes end end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d77b40b..6981e21 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -319,5 +319,5 @@ # Set timeout_in to the timeout interval. Default is 30.minutes - config.timeout_in = 8.hours + config.timeout_in = Rails.application.config.session_management.timeout end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5578484..ec108f1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,3 +31,8 @@ en: errors: messages: user_not_registered: "User Not registered. Please contact a server administrator for a user account" + session_timeout: + warning_message: "Your session will expire in %{minutes} minutes." + stay_signed_in: "Stay signed in" + error_message: "We couldn't extend your session. Please save your work and sign in again." + expired_message: "Your session has expired. Please sign in again to continue." diff --git a/config/routes.rb b/config/routes.rb index 0e9e6a2..f27e5d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,12 @@ Rails.application.routes.draw do - devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" } + devise_for :users, controllers: { + omniauth_callbacks: "users/omniauth_callbacks", + sessions: "users/sessions" + } devise_scope :user do - get "/users/sign_out" => "devise/sessions#destroy" + get "/users/sign_out" => "users/sessions#destroy" + post "/users/session/keepalive" => "users/sessions#keepalive", as: :user_session_keepalive end namespace :admin do diff --git a/spec/requests/session_timeout_spec.rb b/spec/requests/session_timeout_spec.rb new file mode 100644 index 0000000..051298a --- /dev/null +++ b/spec/requests/session_timeout_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Session timeout data", type: :request do + include ActiveSupport::Testing::TimeHelpers + let(:user) { FactoryBot.create(:user) } + let(:warning_lead_time) { Rails.application.config.session_management.warning_lead_time.to_i } + + describe "GET /" do + context "when the user is signed in" do + it "exposes complete session timeout attributes" do + travel_to(Time.zone.local(2024, 1, 1, 12, 0, 0)) do + sign_in user + + get root_path + expect(response).to have_http_status(:ok) + + container = Nokogiri::HTML(response.body).at_css("#flash-messages") + + expect(container["data-controller"]).to include("session-timeout") + + expect(container["data-session-timeout-expires-at-value"].to_i) + .to eq((Time.current + Devise.timeout_in).to_i) + expect(container["data-session-timeout-duration-value"].to_i) + .to eq(Devise.timeout_in.to_i) + expect(container["data-session-timeout-warning-offset-value"].to_i) + .to eq(warning_lead_time) + expect(container["data-session-timeout-keepalive-url-value"]) + .to eq(user_session_keepalive_path) + expect(container["data-session-timeout-warning-message-value"]) + .to eq(I18n.t("session_timeout.warning_message")) + expect(container["data-session-timeout-stay-signed-in-label-value"]) + .to eq(I18n.t("session_timeout.stay_signed_in")) + expect(container["data-session-timeout-error-message-value"]) + .to eq(I18n.t("session_timeout.error_message")) + expect(container["data-session-timeout-expired-message-value"]) + .to eq(I18n.t("session_timeout.expired_message")) + end + end + end + + context "when the user is signed out" do + it "limits data attributes to controller registration" do + get new_user_session_path + expect(response).to have_http_status(:ok) + + container = Nokogiri::HTML(response.body).at_css("#flash-messages") + + expect(container["data-controller"]).to eq("session-timeout") + expect( + container.attribute_nodes.map(&:name) + ).to contain_exactly("id", "data-controller") + end + end + end +end diff --git a/spec/system/session_timeout_spec.rb b/spec/system/session_timeout_spec.rb new file mode 100644 index 0000000..aba72bd --- /dev/null +++ b/spec/system/session_timeout_spec.rb @@ -0,0 +1,407 @@ +require "timeout" +require "rails_helper" + +RSpec.describe "Session Timeout", type: :system, js: true do + include ActiveSupport::Testing::TimeHelpers + + let(:user) { FactoryBot.create(:user, first_name: "Alex") } + let(:warning_offset_seconds) { Rails.application.config.session_management.warning_lead_time.to_i } + let(:session_timeout_seconds) { Devise.timeout_in.to_i } + let(:base_time) { Time.zone.local(2024, 1, 1, 12, 0, 0) } + + before do + travel_to(base_time) + driven_by :cuprite + sign_in user + visit root_path + wait_for_stimulus_application + wait_for_session_timeout_controller + sync_client_clock + end + + after do + restore_client_clock + travel_back + end + + it "shows a countdown warning before the session expires" do + configure_session_timeout( + expires_in: warning_offset_seconds + 120, + warning_offset: warning_offset_seconds, + duration: session_timeout_seconds + ) + advance_session_time_by((warning_offset_seconds - 60).seconds) + advance_session_time_by(60.seconds) + show_warning_alert + + expect(page).to have_css( + ".alert-warning", + text: expected_warning_message(warning_offset_seconds) + ) + end + + it "lets the user extend the session from the warning alert" do + configure_session_timeout( + expires_in: warning_offset_seconds + 5, + warning_offset: warning_offset_seconds, + duration: session_timeout_seconds + ) + advance_session_time_by((warning_offset_seconds - 60).seconds) + advance_session_time_by(60.seconds) + show_warning_alert + + original_expiration = page.evaluate_script(session_timeout_expires_at_js) + new_expiration = (Time.current + 10.minutes).to_i + + page.execute_script(<<~JS, new_expiration) + (function(expiresAt) { + const element = document.querySelector("#flash-messages"); + if (!window.StimulusApp || !element) return; + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + if (!controller) return; + + window.__keepaliveCalls = 0; + window.__expectedExpiresAt = expiresAt; + const keepaliveUrl = controller.keepaliveUrlValue; + const originalFetch = window.fetch; + let promiseResolver; + let pendingPromise; + + window.fetch = (...args) => { + const request = args[0]; + const url = typeof request === "string" ? request : request && request.url; + const isKeepalive = url === keepaliveUrl; + + if (isKeepalive) { + window.__keepaliveCalls += 1; + if (window.__keepaliveCalls === 1) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ expires_at: expiresAt }) + }); + } + if (pendingPromise) { + return pendingPromise; + } + pendingPromise = new Promise((resolve) => { + promiseResolver = resolve; + }); + return pendingPromise; + } + + return originalFetch ? originalFetch(...args) : Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; + + window.__resolvePendingKeepalive = () => { + if (promiseResolver) { + promiseResolver({ ok: true, json: () => Promise.resolve({}) }); + promiseResolver = null; + pendingPromise = null; + } + }; + })(arguments[0]); + JS + + click_button I18n.t("session_timeout.stay_signed_in") + + page.execute_script("window.__resolvePendingKeepalive && window.__resolvePendingKeepalive()") + + expect(page).to have_no_css(".alert-warning") + expect(page.evaluate_script("window.__keepaliveCalls")).to be >= 1 + controller_expires_at = page.evaluate_script(session_timeout_expires_at_js) + expect(controller_expires_at).to be > original_expiration + expect(controller_expires_at).to be_between(new_expiration, new_expiration + session_timeout_seconds).inclusive + + page.all("[data-session-timeout-signed-in]").each do |element| + expect(element[:class].to_s).not_to include("d-none") + end + page.all("[data-session-timeout-signed-out]").each do |element| + expect(element[:class].to_s).to include("d-none") + end + end + + it "displays an expiration alert and toggles navigation when the session lapses" do + configure_session_timeout( + expires_in: warning_offset_seconds + 300, + warning_offset: warning_offset_seconds, + duration: session_timeout_seconds + ) + advance_session_time_by((warning_offset_seconds - 60).seconds) + advance_session_time_by(60.seconds) + advance_session_time_by(300.seconds) + force_session_expiration + + expect(page).to have_no_css(".alert-warning") + expect(page).to have_css(".alert-danger", text: I18n.t("session_timeout.expired_message")) + + page.all("[data-session-timeout-signed-in]").each do |element| + expect(element[:class].to_s).to include("d-none") + end + page.all("[data-session-timeout-signed-out]").each do |element| + expect(element[:class].to_s).not_to include("d-none") + end + end + + def expected_warning_message(seconds) + I18n.t("session_timeout.warning_message", minutes: minutes_from_seconds(seconds)) + end + + def minutes_from_seconds(seconds) + [ (seconds / 60.0).ceil, 1 ].max + end + + def wait_for_session_timeout_controller + page.find("#flash-messages", visible: :all) + + Timeout.timeout(15) do + loop do + controller_ready = page.evaluate_script(session_timeout_controller_present_js) + break if controller_ready + sleep 0.05 + end + end + end + + def wait_for_stimulus_application + load_stimulus_library + start_stimulus_application + register_session_timeout_controller + + Timeout.timeout(15) do + loop do + ready = page.evaluate_script(session_timeout_controller_present_js) + break if ready + sleep 0.05 + end + end + rescue Timeout::Error + raise "Stimulus application did not load" + end + + def load_stimulus_library + page.execute_script(<<~JS, stimulus_library_source) + (function(source) { + if (window.__stimulusLibraryLoaded) { + window.StimulusLib = window.__stimulusLibraryReference || window.StimulusLib || window.Stimulus; + return; + } + const script = document.createElement("script"); + script.type = "text/javascript"; + script.text = source; + document.head.appendChild(script); + window.__stimulusLibraryLoaded = true; + window.__stimulusLibraryReference = window.Stimulus; + window.StimulusLib = window.__stimulusLibraryReference; + })(arguments[0]); + JS + end + + def start_stimulus_application + page.execute_script(<<~JS) + (function() { + if (!window.__stimulusLibraryLoaded) return; + if (!window.StimulusApp) { + const library = window.StimulusLib || window.Stimulus; + const application = library.Application.start(); + window.StimulusApp = application; + window.Stimulus = application; + window.__sessionTimeoutControllerRegistered = false; + } + })(); + JS + end + + def register_session_timeout_controller + page.execute_script(<<~JS, session_timeout_controller_source) + (function(controllerFactorySource) { + if (!window.StimulusApp || window.__sessionTimeoutControllerRegistered) return; + const factory = new Function("Stimulus", controllerFactorySource); + const ControllerClass = factory(window.StimulusLib || window.StimulusApp); + window.StimulusApp.register("session-timeout", ControllerClass); + window.__sessionTimeoutControllerRegistered = true; + })(arguments[0]); + JS + end + + def show_warning_alert + wait_for_session_timeout_controller + visible = page.evaluate_script(<<~JS, warning_offset_seconds) + (function(seconds) { + const element = document.querySelector("#flash-messages"); + if (!window.StimulusApp || !element) return; + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + if (!controller) return false; + if (typeof controller.hideWarning === "function") controller.hideWarning(); + controller.warningOffsetValue = seconds; + controller.showWarning(); + return Boolean(controller.warningElement || document.querySelector("#flash-messages .alert-warning")); + })(arguments[0]); + JS + raise "Unable to render session timeout warning" unless visible + end + + def force_session_expiration + wait_for_session_timeout_controller + expired = page.evaluate_script(<<~JS) + (function() { + const element = document.querySelector("#flash-messages"); + if (!window.StimulusApp || !element) return; + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + if (!controller) return false; + controller.handleExpiration(); + return Boolean(document.querySelector("#flash-messages .alert-danger")); + })(); + JS + raise "Unable to render session timeout expiration" unless expired + end + + def stimulus_library_source + @stimulus_library_source ||= Rails.root.join("node_modules/@hotwired/stimulus/dist/stimulus.umd.js").read + end + + def session_timeout_controller_source + @session_timeout_controller_source ||= begin + source = Rails.root.join("app/javascript/controllers/session_timeout_controller.js").read + source = source.each_line.reject { |line| line.strip.start_with?("import ") }.join + source = source.sub("export default class extends Controller", "class SessionTimeoutController extends Stimulus.Controller") + <<~JS + return (function() { + #{source} + return SessionTimeoutController; + })(); + JS + end + end + + def session_timeout_controller_present_js + @session_timeout_controller_present_js ||= <<~JS + (function() { + if (!window.StimulusApp) return false; + const element = document.querySelector("#flash-messages"); + if (!element) return false; + const controllers = window.StimulusApp.controllers || []; + if (controllers.some(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + })) { + return true; + } + if (typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + return Boolean(window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout")); + } + return false; + })() + JS + end + + def session_timeout_expires_at_js + @session_timeout_expires_at_js ||= <<~JS + (function() { + if (!window.StimulusApp) return null; + const element = document.querySelector("#flash-messages"); + if (!element) return null; + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + return controller ? controller.expiresAtValue : null; + })() + JS + end + + def configure_session_timeout(expires_in:, warning_offset:, duration: expires_in) + now_seconds = Time.current.to_i + Timeout.timeout(5) do + loop do + configured = page.evaluate_script(<<~JS, now_seconds, warning_offset, duration, expires_in) + (function(now, warningOffset, duration, expiresIn) { + const element = document.querySelector("#flash-messages"); + if (!window.StimulusApp || !element) return false; + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + if (!controller) return false; + controller.warningOffsetValue = warningOffset; + controller.durationValue = duration; + controller.expiresAtValue = now + expiresIn; + controller.resetTimers(); + return true; + })(arguments[0], arguments[1], arguments[2], arguments[3]); + JS + break if configured + sleep 0.05 + end + end + end + + def advance_session_time_by(duration) + travel duration + sync_client_clock + recalculate_timers + end + + def sync_client_clock + now_ms = (Time.current.to_f * 1000).to_i + page.execute_script(<<~JS, now_ms) + (function(nowMs) { + if (!window.__originalDateNow) { + window.__originalDateNow = Date.now; + } + window.__testDateNow = nowMs; + Date.now = () => window.__testDateNow; + })(arguments[0]); + JS + end + + def recalculate_timers + page.execute_script(<<~JS) + (function() { + const element = document.querySelector("#flash-messages"); + const controllers = window.StimulusApp.controllers || []; + let controller = controllers.find(function(controller) { + return controller.element === element && controller.identifier === "session-timeout"; + }); + if (!controller && typeof window.StimulusApp.getControllerForElementAndIdentifier === "function") { + controller = window.StimulusApp.getControllerForElementAndIdentifier(element, "session-timeout"); + } + controller && controller.resetTimers(); + })(); + JS + end + + def restore_client_clock + page.execute_script(<<~JS) + if (window.__originalDateNow) { + Date.now = window.__originalDateNow; + delete window.__originalDateNow; + delete window.__testDateNow; + } + JS + rescue StandardError + # Ignored - the browser may already be closed. + end +end