Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/admin/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user!

include NavigationData
include SessionTimeoutData
end
35 changes: 35 additions & 0 deletions app/controllers/concerns/session_timeout_data.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/javascript/application.js
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 3 additions & 1 deletion app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
@@ -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 }
export { application }
223 changes: 223 additions & 0 deletions app/javascript/controllers/session_timeout_controller.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="d-flex flex-column flex-sm-row align-items-sm-center justify-content-between gap-2">
<span>${messageText}</span>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-primary" data-action="click->session-timeout#resetSession">
${staySignedInLabel}
</button>
</div>
</div>
`;

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 = `
<span>${message}</span>
<button type="button" class="btn-close" data-action="click->session-timeout#hideAlert" aria-label="Close"></button>
`;

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);
}
}
4 changes: 3 additions & 1 deletion app/views/layouts/administrate/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@

<div class="app-container">
<main class="main-content" role="main">
<%= render 'shared/flashes' %>
<%= content_tag :div, id: "flash-messages", data: session_timeout_data do %>
<%= render 'shared/flashes' %>
<% end %>
<%= yield %>
</main>
</div>
Expand Down
4 changes: 3 additions & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
<%= render 'shared/global_navigation' %>

<div class="container">
<%= render 'shared/flashes' %>
<%= content_tag :div, id: "flash-messages", data: session_timeout_data do %>
<%= render 'shared/flashes' %>
<% end %>

<%= yield %>
</div>
Expand Down
Loading