Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[34] Timeout Warning #115

Merged
merged 17 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whitespace

Suggested change
ruby 3.2.4
ruby 3.2.4

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;
}
}
17 changes: 17 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,27 @@ 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
if logged_in? && (session[:session_timeout_at].blank? || session[:session_timeout_at] < Time.current)
cpreisinger marked this conversation as resolved.
Show resolved Hide resolved
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
13 changes: 13 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
class SessionsController < ApplicationController
before_action :check_error_result, :require_code_param, :exchange_token, only: [:result]

SESSION_TIMEOUT_IN_MINUTES = 15

def new
# TODO: handle redirect to login page due to inactivity
end
Expand All @@ -24,6 +26,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
stepchud marked this conversation as resolved.
Show resolved Hide resolved
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
Loading