Skip to content

Commit 7615b84

Browse files
authored
Merge pull request #184 from tulibraries/IMT-164-Session_timeout_warning
IMT-164 session timeout warning
2 parents 253280f + a29502b commit 7615b84

File tree

16 files changed

+790
-26
lines changed

16 files changed

+790
-26
lines changed

app/controllers/admin/application_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ApplicationController < Administrate::ApplicationController
99
before_action :authenticate_user!
1010

1111
include NavigationData
12+
include SessionTimeoutData
1213

1314
# Override this value to specify the number of elements to display at a time
1415
# on index pages. Defaults to 20.

app/controllers/application_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ class ApplicationController < ActionController::Base
44
before_action :authenticate_user!
55

66
include NavigationData
7+
include SessionTimeoutData
78
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module SessionTimeoutData
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
helper_method :session_timeout_data if respond_to?(:helper_method, true)
6+
end
7+
8+
private
9+
10+
def session_timeout_data
11+
data = { controller: "session-timeout" }
12+
return data unless user_signed_in?
13+
14+
timeout_in_seconds = Devise.timeout_in.to_i
15+
session_data = request.env.fetch("warden", nil)&.session(:user) || {}
16+
last_request_at = session_data && session_data["last_request_at"]
17+
18+
expires_at = if last_request_at.present?
19+
last_request_at.to_i + timeout_in_seconds
20+
else
21+
Time.current.to_i + timeout_in_seconds
22+
end
23+
24+
data.merge(
25+
session_timeout_expires_at_value: expires_at,
26+
session_timeout_duration_value: timeout_in_seconds,
27+
session_timeout_warning_offset_value: Rails.application.config.session_management.warning_lead_time.to_i,
28+
session_timeout_keepalive_url_value: user_session_keepalive_path,
29+
session_timeout_warning_message_value: t("session_timeout.warning_message"),
30+
session_timeout_stay_signed_in_label_value: t("session_timeout.stay_signed_in"),
31+
session_timeout_error_message_value: t("session_timeout.error_message"),
32+
session_timeout_expired_message_value: t("session_timeout.expired_message")
33+
)
34+
end
35+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module Users
2+
# Handles session-related customizations for Devise
3+
class SessionsController < Devise::SessionsController
4+
before_action :authenticate_user!, only: :keepalive
5+
skip_before_action :require_no_authentication, only: :new
6+
before_action :redirect_signed_in_users, only: :new
7+
8+
def keepalive
9+
expires_at = Time.current.to_i + Devise.timeout_in.to_i
10+
render json: { expires_at: expires_at }
11+
end
12+
13+
private
14+
15+
def redirect_signed_in_users
16+
return unless user_signed_in?
17+
18+
respond_to do |format|
19+
format.html { redirect_to after_sign_in_path_for(current_user) }
20+
format.any { head :no_content }
21+
end
22+
end
23+
end
24+
end

app/javascript/application.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "@hotwired/turbo-rails";
2-
import "./controllers";
2+
import { application } from "./controllers";
3+
window.Stimulus = application;
34
import * as bootstrap from "bootstrap";
45
window.bootstrap = bootstrap; // Make Bootstrap globally available
56

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Application } from "@hotwired/stimulus";
22
import WunderbaumController from "./wunderbaum_controller";
33
import BatchActionsController from "./batch_actions_controller";
4+
import SessionTimeoutController from "./session_timeout_controller";
45

56
const application = Application.start();
67

78
application.register("wunderbaum", WunderbaumController);
89
application.register("batch-actions", BatchActionsController);
10+
application.register("session-timeout", SessionTimeoutController);
911

10-
export { application }
12+
export { application }
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
3+
// Manages Devise session timeout warnings and keepalive behavior.
4+
export default class extends Controller {
5+
static values = {
6+
expiresAt: Number,
7+
duration: Number,
8+
warningOffset: Number,
9+
keepaliveUrl: String,
10+
warningMessage: String,
11+
staySignedInLabel: String,
12+
errorMessage: String,
13+
expiredMessage: String,
14+
};
15+
16+
connect() {
17+
this.flashElement = this.element;
18+
if (!this.flashElement) return;
19+
this.requestInFlight = false;
20+
this.cacheMenuElements();
21+
this.toggleMenus(this.hasExpiresAtValue);
22+
23+
this.resetTimers();
24+
}
25+
26+
disconnect() {
27+
this.clearTimers();
28+
this.hideWarning();
29+
}
30+
31+
resetTimers() {
32+
this.clearTimers();
33+
34+
if (!this.hasExpiresAtValue || !this.hasWarningOffsetValue) return;
35+
36+
const warningAt = (this.expiresAtValue - this.warningOffsetValue) * 1000;
37+
const expirationAt = this.expiresAtValue * 1000;
38+
const millisUntilWarning = warningAt - Date.now();
39+
const millisUntilExpiration = expirationAt - Date.now();
40+
41+
if (millisUntilWarning <= 0) {
42+
this.showWarning();
43+
} else {
44+
this.warningTimer = setTimeout(() => this.showWarning(), millisUntilWarning);
45+
}
46+
47+
if (millisUntilExpiration <= 0) {
48+
this.handleExpiration();
49+
} else {
50+
this.expirationTimer = setTimeout(() => this.handleExpiration(), millisUntilExpiration);
51+
}
52+
}
53+
54+
resetSession(event) {
55+
event.preventDefault();
56+
if (!this.hasKeepaliveUrlValue || this.requestInFlight) return;
57+
this.requestInFlight = true;
58+
59+
fetch(this.keepaliveUrlValue, {
60+
method: "POST",
61+
headers: {
62+
"X-CSRF-Token": this.csrfToken,
63+
"Accept": "application/json",
64+
"Content-Type": "application/json",
65+
},
66+
credentials: "same-origin",
67+
})
68+
.then((response) => {
69+
if (!response.ok) throw new Error("Keepalive request failed");
70+
return response.json().catch(() => ({}));
71+
})
72+
.then((data) => {
73+
if (data.expires_at) {
74+
this.expiresAtValue = data.expires_at;
75+
} else if (this.hasDurationValue) {
76+
this.expiresAtValue = Math.floor(Date.now() / 1000) + this.durationValue;
77+
}
78+
this.hideWarning();
79+
this.resetTimers();
80+
this.toggleMenus(true);
81+
})
82+
.catch(() => this.showError())
83+
.finally(() => {
84+
this.requestInFlight = false;
85+
});
86+
}
87+
88+
showWarning() {
89+
if (!this.flashElement || this.warningVisible) return;
90+
91+
const remainingMinutes = this.minutesFromSeconds(this.warningOffsetValue);
92+
const messageTemplate = this.valueOrDefault("warningMessage", "Your session will expire in %{minutes} minutes.");
93+
const messageText = messageTemplate.replace("%{minutes}", remainingMinutes);
94+
const staySignedInLabel = this.valueOrDefault("staySignedInLabel", "Stay signed in");
95+
96+
const alert = document.createElement("div");
97+
alert.className = "alert alert-warning alert-dismissible fade show mt-3";
98+
alert.setAttribute("role", "alert");
99+
alert.innerHTML = `
100+
<div class="d-flex flex-column flex-sm-row align-items-sm-center justify-content-between gap-2">
101+
<span>${messageText}</span>
102+
<div class="d-flex gap-2">
103+
<button type="button" class="btn btn-sm btn-primary" data-action="click->session-timeout#resetSession">
104+
${staySignedInLabel}
105+
</button>
106+
</div>
107+
</div>
108+
`;
109+
110+
this.flashElement.prepend(alert);
111+
this.warningElement = alert;
112+
this.warningVisible = true;
113+
}
114+
115+
hideWarning() {
116+
if (!this.warningVisible || !this.warningElement) return;
117+
this.warningElement.remove();
118+
this.warningElement = null;
119+
this.warningVisible = false;
120+
}
121+
122+
showError() {
123+
if (!this.flashElement) return;
124+
this.hideWarning();
125+
this.showAlert({
126+
level: "danger",
127+
message: this.valueOrDefault("errorMessage", "We couldn't extend your session. Please save your work and sign in again."),
128+
});
129+
}
130+
131+
clearTimers() {
132+
if (this.warningTimer) {
133+
clearTimeout(this.warningTimer);
134+
this.warningTimer = null;
135+
}
136+
if (this.expirationTimer) {
137+
clearTimeout(this.expirationTimer);
138+
this.expirationTimer = null;
139+
}
140+
}
141+
142+
get csrfToken() {
143+
const element = document.querySelector("meta[name='csrf-token']");
144+
return element && element.getAttribute("content");
145+
}
146+
147+
handleExpiration() {
148+
this.clearTimers();
149+
this.hideWarning();
150+
if (!this.flashElement) return;
151+
this.toggleMenus(false);
152+
153+
this.showAlert({
154+
level: "danger",
155+
message: this.valueOrDefault("expiredMessage", "Your session has expired. Please sign in again to continue."),
156+
trackWarning: true,
157+
});
158+
}
159+
160+
showAlert({ level, message, trackWarning = false }) {
161+
const alert = document.createElement("div");
162+
alert.className = `alert alert-${level} alert-dismissible fade show mt-3`;
163+
alert.setAttribute("role", "alert");
164+
alert.innerHTML = `
165+
<span>${message}</span>
166+
<button type="button" class="btn-close" data-action="click->session-timeout#hideAlert" aria-label="Close"></button>
167+
`;
168+
169+
this.flashElement.prepend(alert);
170+
171+
if (trackWarning) {
172+
this.warningElement = alert;
173+
this.warningVisible = true;
174+
}
175+
}
176+
177+
hideAlert(event) {
178+
event.preventDefault();
179+
const alert = event.target.closest(".alert");
180+
if (!alert) return;
181+
if (alert === this.warningElement) {
182+
this.warningElement = null;
183+
this.warningVisible = false;
184+
}
185+
alert.remove();
186+
}
187+
188+
minutesFromSeconds(seconds) {
189+
const minutes = Math.ceil(seconds / 60);
190+
return Math.max(minutes, 1);
191+
}
192+
193+
valueOrDefault(name, fallback = "") {
194+
const hasKey = this[`has${this.capitalize(name)}Value`];
195+
if (hasKey) {
196+
return this[`${name}Value`];
197+
}
198+
return fallback;
199+
}
200+
201+
capitalize(value) {
202+
return value.charAt(0).toUpperCase() + value.slice(1);
203+
}
204+
205+
cacheMenuElements() {
206+
this.signedInMenus = Array.from(document.querySelectorAll("[data-session-timeout-signed-in]"));
207+
this.signedOutMenus = Array.from(document.querySelectorAll("[data-session-timeout-signed-out]"));
208+
}
209+
210+
toggleMenus(isSignedIn) {
211+
if (!this.signedInMenus || !this.signedOutMenus) {
212+
this.cacheMenuElements();
213+
}
214+
215+
this.signedInMenus?.forEach((element) => this.setVisibility(element, isSignedIn));
216+
this.signedOutMenus?.forEach((element) => this.setVisibility(element, !isSignedIn));
217+
}
218+
219+
setVisibility(element, shouldShow) {
220+
if (!element) return;
221+
element.classList.toggle("d-none", !shouldShow);
222+
}
223+
}

app/views/layouts/administrate/application.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
<div class="app-container">
2323
<main class="main-content" role="main">
24-
<%= render 'shared/flashes' %>
24+
<%= content_tag :div, id: "flash-messages", data: session_timeout_data do %>
25+
<%= render 'shared/flashes' %>
26+
<% end %>
2527
<%= yield %>
2628
</main>
2729
</div>

app/views/layouts/application.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
<%= render 'shared/global_navigation' %>
2424

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

2830
<%= yield %>
2931
</div>

0 commit comments

Comments
 (0)