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? %>
-
-
-
- <%= current_user.name || current_user.email %>
-
-
-
- <% else %>
-
- <%= link_to "Sign In", new_user_session_path, class: "nav-link" %>
-
- <% end %>
+
+
+
+ <%= current_user&.name || current_user&.email %>
+
+
+
+
+ <%= link_to "Sign In", new_user_session_path, class: "nav-link" %>
+
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