Skip to content

Commit

Permalink
Merge pull request #115 from GSA/34/timeout-warning
Browse files Browse the repository at this point in the history
[34] Timeout Warning
  • Loading branch information
cpreisinger authored Aug 27, 2024
2 parents 2d8ef24 + b7f1c15 commit f904630
Show file tree
Hide file tree
Showing 12 changed files with 255 additions and 3 deletions.
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
ruby 3.2.4
ruby 3.2.4
nodejs 20.15.1
yarn 1.22.22
yarn 1.22.22
3 changes: 2 additions & 1 deletion app/assets/uswds/_uswds-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ USWDS with settings overrides
Add a list of changed settings in the form $setting: value.
----------------------------------------
*/

@use "uswds-core" with ($theme-image-path: "images",
$theme-font-path: "fonts"
);
Expand Down Expand Up @@ -55,4 +56,4 @@ Add a list of changed settings in the form $setting: value.

.usa-footer__secondary-section .usa-social-link:hover .usa-social-link__icon {
background-color: white;
}
}
19 changes: 19 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
class ApplicationController < ActionController::Base
helper_method :current_user, :logged_in?

before_action :check_session_expiration

def current_user
return unless session[:userinfo]

Expand All @@ -18,12 +20,29 @@ def sign_in(login_userinfo)
user = User.user_from_userinfo(login_userinfo)

@current_user = user
renew_session
session[:userinfo] = login_userinfo
end

def sign_out
@current_user = nil
session.delete(:userinfo)
session.delete(:session_timeout_at)
end

def renew_session
session[:session_timeout_at] = Time.current + SessionsController::SESSION_TIMEOUT_IN_MINUTES.minutes
end

def check_session_expiration
return unless logged_in?

if session[:session_timeout_at].blank? || session[:session_timeout_at] < Time.current
sign_out
redirect_to dashboard_path, alert: I18n.t("session_expired_alert")
else
renew_session
end
end

def redirect_if_logged_in(path = "/dashboard")
Expand Down
14 changes: 14 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

class SessionsController < ApplicationController
before_action :check_error_result, :require_code_param, :exchange_token, only: [:result]
skip_before_action :check_session_expiration, only: [:timeout]

SESSION_TIMEOUT_IN_MINUTES = 15

def new
# TODO: handle redirect to login page due to inactivity
Expand All @@ -24,6 +27,17 @@ def result
redirect_to dashboard_path
end

def renew
renew_session
head(:ok)
end

def timeout
sign_out
flash[:alert] = I18n.t("session_expired_alert")
head(:ok)
end

private

def check_error_result
Expand Down
128 changes: 128 additions & 0 deletions app/javascript/session_timeout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { formatMilliseconds } from "./time_helpers";

document.addEventListener("DOMContentLoaded", function () {
var sessionStartTime = Date.now();

const sessionTimeoutMinutes = window.appConfig.sessionTimeoutMinutes;
const warningTimeoutMinutes = 2;
const sessionTimeoutMs = sessionTimeoutMinutes * 60 * 1000;
const warningTimeoutMs =
(sessionTimeoutMinutes - warningTimeoutMinutes) * 60 * 1000;
// Debug overrides
// const sessionTimeoutMs = 10000;
// const warningTimeoutMs = 5000;

const renewalModal = document.getElementById("renew-modal");
const renewalModalOpenButton = document.getElementById(
"renew-modal-open-button"
);
const renewalModalCloseButton = document.getElementById(
"renew-modal-close-button"
);
const countdownDiv = document.querySelector("#renew-modal .countdown");
countdownDiv.textContent = formatMilliseconds(warningTimeoutMs);

const activityRenewalInterval = 1000;
var doRenewSession = false;
var ignoreNextActivity = false;

const showTimeoutWarning = () => {
ignoreNextActivity = true;
renewalModalOpenButton.click();
ignoreNextActivity = false;
};

const updateCountdown = () => {
var timeRemaining = sessionTimeoutMs - (Date.now() - sessionStartTime);
countdownDiv.textContent = formatMilliseconds(timeRemaining);
};

const logoutSession = () => {
fetch("/sessions/timeout", {
method: "DELETE",
headers: {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')
.content,
"Content-Type": "application/json",
},
}).then((response) => {
if (response.ok) {
window.location.href = "/";
}
});
};

const handleUserActivity = (event) => {
// Don't count the modal showing and hiding as user activity
if (ignoreNextActivity) {
return;
}

ignoreNextActivity = true;
renewalModalCloseButton.click();
ignoreNextActivity = false;

doRenewSession = true;
};

document.addEventListener("click", handleUserActivity);
document.addEventListener("keydown", handleUserActivity);
document.addEventListener("scroll", handleUserActivity);

setInterval(() => {
if (doRenewSession) {
renewSession();
}
}, activityRenewalInterval);

document
.getElementById("extend-session-button")
.addEventListener("click", () => {
renewSession();
});

var renewSession = () => {
fetch("/sessions/renew", {
method: "POST",
headers: {
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
},
}).then((response) => {
if (response.ok) {
clearTimeout(timeoutWarning);
clearTimeout(sessionTimeout);

sessionStartTime = Date.now();

timeoutWarning = setTimeout(() => {
showTimeoutWarning();
}, warningTimeoutMs);

sessionTimeout = setTimeout(() => {
logoutSession();
}, sessionTimeoutMs);

doRenewSession = false;
}
});
};

var timeoutWarning = setTimeout(() => {
showTimeoutWarning();
}, warningTimeoutMs);

var sessionTimeout = setTimeout(() => {
logoutSession();
}, sessionTimeoutMs);

var countdownInterval;

var startCountdown = () => {
clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdown, 1000);
};

startCountdown();
});
6 changes: 6 additions & 0 deletions app/javascript/time_helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function formatMilliseconds(ms) {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
}
14 changes: 14 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,25 @@
<%= javascript_include_tag 'application' %>
<%= javascript_include_tag 'uswds', async: true %>
<%= javascript_include_tag 'uswds-init', async: true %>

<% if logged_in? %>
<script>
window.appConfig = {
sessionTimeoutMinutes: <%= SessionsController::SESSION_TIMEOUT_IN_MINUTES %>
}
</script>

<%= javascript_include_tag 'session_timeout' %>
<% end %>
</head>

<body>
<%= render "layouts/header" %>
<%= render "shared/flash" %>

<%= yield %>

<%= render "layouts/footer" %>
<%= render "modals/renew_session" %>
</body>
</html>
26 changes: 26 additions & 0 deletions app/views/modals/_renew_session.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<a href="#renew-modal" style="display:none;" id="renew-modal-open-button" class="usa-button" aria-controls="renew-modal" data-open-modal></a>

<div
class="usa-modal"
id="renew-modal"
aria-labelledby="modal-1-heading"
aria-describedby="modal-1-description"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Session expire
</h2>
<div class="usa-prose">
<p id="modal-1-description">
Your session will expire in <span class="countdown"></span><br>
Please click below if you would like to continue.
</p>
</div>
<div class="usa-modal__footer">
<button class="usa-button modal-btn" id="extend-session-button" data-close-modal type="button">Renew Session</button>
</div>
</div>
<a href="#renew-modal" style="display:none;" id="renew-modal-close-button" class="usa-button" data-close-modal></a>
</div>
</div>
14 changes: 14 additions & 0 deletions app/views/shared/_flash.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<% flash.each do |key, value| %>
<% alert_class = case key.to_sym
when :notice then "usa-alert--success"
when :alert then "usa-alert--error"
when :error then "usa-alert--error"
else "usa-alert--info"
end %>

<div class="usa-alert <%= alert_class %> usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text"><%= value %></p>
</div>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ en:
please_try_again: "Please try again."
login_error: "There was an issue with logging in. Please try again."
already_logged_in_notice: "You are already logged in."
session_expired_alert: "Your session has expired. Please log in again."
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
get 'auth/result', to: 'sessions#result'
resource 'session', only: [:new, :create, :destroy]
post 'sessions/renew', to: 'sessions#renew'
delete 'sessions/timeout', to: 'sessions#timeout'

get '/', to: "dashboard#index"
get '/dashboard', to: "dashboard#index"
Expand Down
27 changes: 27 additions & 0 deletions spec/requests/sessions_request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,31 @@
expect(response).to have_http_status(:redirect)
expect(response).to redirect_to("/dashboard")
end

it "times out the session" do
session_timeout_in_minutes = SessionsController::SESSION_TIMEOUT_IN_MINUTES

email = "[email protected]"
token = SecureRandom.uuid

User.create!({ email:, token: })

code = "ABC123"
login_gov = instance_double(LoginGov)
allow(LoginGov).to receive(:new).and_return(login_gov)
allow(login_gov).to receive(:exchange_token_from_auth_result).with(code).and_return(
[{ email:, sub: token }]
)
get "/auth/result", params: { code: }

expect(session[:userinfo]).not_to be_nil
expect(session[:session_timeout_at]).not_to be_nil

travel_to (session_timeout_in_minutes.to_i + 1).minutes.from_now do
get dashboard_path

expect(session[:userinfo]).to be_nil
expect(session[:session_timeout_at]).to be_nil
end
end
end

0 comments on commit f904630

Please sign in to comment.