Skip to content

Commit f92527a

Browse files
jd/implements automatic logout modal (#440)
1 parent b2d8542 commit f92527a

File tree

8 files changed

+289
-2
lines changed

8 files changed

+289
-2
lines changed

query-connector/__mocks__/next-auth/react.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ const useSession = jest
44
.fn()
55
.mockReturnValue({ data: undefined, status: "loading" });
66

7+
const signOut = jest.fn();
78
/**
89
*
910
* @param root0
1011
* @param root0.children
1112
*/
1213
const SessionProvider = ({ children }) => <>{children}</>;
1314

14-
export { useSession, SessionProvider };
15+
export { useSession, signOut, SessionProvider };

query-connector/__mocks__/tabbable.js

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/* eslint-disable jsdoc/require-returns, jsdoc/require-param-description */
2+
// __mocks__/tabbable.js
3+
4+
const lib = jest.requireActual("tabbable");
5+
6+
const tabbable = {
7+
...lib,
8+
/**
9+
*
10+
* @param node
11+
* @param options
12+
*/
13+
tabbable: (node, options) =>
14+
lib.tabbable(node, { ...options, displayCheck: "none" }),
15+
/**
16+
*
17+
* @param node
18+
* @param options
19+
*/
20+
focusable: (node, options) =>
21+
lib.focusable(node, { ...options, displayCheck: "none" }),
22+
/**
23+
*
24+
* @param node
25+
* @param options
26+
*/
27+
isFocusable: (node, options) =>
28+
lib.isFocusable(node, { ...options, displayCheck: "none" }),
29+
/**
30+
*
31+
* @param node
32+
* @param options
33+
*/
34+
isTabbable: (node, options) =>
35+
lib.isTabbable(node, { ...options, displayCheck: "none" }),
36+
};
37+
38+
module.exports = tabbable;

query-connector/package-lock.json

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

query-connector/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"react": "^18",
4242
"react-dom": "^18",
4343
"react-highlight-words": "^0.21.0",
44+
"react-idle-timer": "^5.7.2",
4445
"react-loading-skeleton": "^3.5.0",
4546
"react-toastify": "^10.0.5",
4647
"sharp": "^0.33.5"

query-connector/src/app/layout.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { Metadata } from "next";
77
import Page from "./ui/components/page/page";
88
import { auth } from "@/auth";
99
import { isAuthDisabledServerCheck } from "./utils/auth";
10+
import SessionTimeout, {
11+
IDLE_TIMEOUT_MSEC,
12+
PROMPT_TIMEOUT_MSEC,
13+
} from "./ui/components/sessionTimeout/sessionTimeout";
1014

1115
/**
1216
* Establishes the layout for the application.
@@ -32,6 +36,10 @@ export default async function RootLayout({
3236
<body>
3337
<div className="application-container">
3438
<DataProvider runtimeConfig={runtimeConfig} session={session}>
39+
<SessionTimeout
40+
idleTimeMsec={IDLE_TIMEOUT_MSEC}
41+
promptTimeMsec={PROMPT_TIMEOUT_MSEC}
42+
/>
3543
<Header authDisabled={isAuthDisabledServerCheck()} />
3644
<Page showSiteAlert={process.env.DEMO_MODE === "true"}>
3745
{children}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { renderWithUser, RootProviderMock } from "@/app/tests/unit/setup";
2+
import { act, render, screen, waitFor } from "@testing-library/react";
3+
import { SessionContextValue } from "next-auth/react";
4+
import * as nextAuthReact from "next-auth/react";
5+
import SessionTimeout from "./sessionTimeout";
6+
import { UserRole } from "@/app/models/entities/users";
7+
import { Session } from "next-auth";
8+
import { PAGES } from "@/app/shared/page-routes";
9+
10+
jest.mock("next-auth/react");
11+
jest.mock("tabbable");
12+
13+
describe("SessionTimeout", () => {
14+
it("Does not display when user is unauthenticated", async () => {
15+
const session: SessionContextValue = {
16+
data: null,
17+
status: "unauthenticated",
18+
update: jest.fn(),
19+
};
20+
21+
jest.spyOn(nextAuthReact, "useSession").mockReturnValueOnce(session);
22+
23+
render(
24+
<RootProviderMock currentPage={"/"}>
25+
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
26+
</RootProviderMock>,
27+
);
28+
29+
// give opportunity for a potential modal to display
30+
await act(async () => {
31+
await new Promise((resolve) => setTimeout(resolve, 1000));
32+
});
33+
34+
const dialog = screen.getByRole("dialog", { hidden: true });
35+
expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-hidden");
36+
});
37+
38+
it("Display when user is authenticated", async () => {
39+
const session: SessionContextValue = {
40+
data: { user: { role: UserRole.STANDARD } } as Session,
41+
status: "authenticated",
42+
update: jest.fn(),
43+
};
44+
45+
jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
46+
const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
47+
48+
render(
49+
<RootProviderMock currentPage={"/"}>
50+
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
51+
</RootProviderMock>,
52+
);
53+
54+
const dialog = screen.getByRole("dialog", { hidden: true });
55+
await waitFor(() =>
56+
expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-visible"),
57+
);
58+
59+
await waitFor(() =>
60+
expect(signOutSpy).toHaveBeenCalledWith({ redirectTo: PAGES.LANDING }),
61+
);
62+
jest.clearAllMocks();
63+
});
64+
65+
it("Resets idle timer if user decides to stay", async () => {
66+
const session: SessionContextValue = {
67+
data: { user: { role: UserRole.STANDARD } } as Session,
68+
status: "authenticated",
69+
update: jest.fn(),
70+
};
71+
72+
jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
73+
const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
74+
75+
const { user } = renderWithUser(
76+
<RootProviderMock currentPage={"/"}>
77+
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
78+
</RootProviderMock>,
79+
);
80+
81+
const dialog = screen.getByRole("dialog", { hidden: true });
82+
await waitFor(() =>
83+
expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-visible"),
84+
);
85+
const stayBtn = screen.getByRole("button", {
86+
name: /yes, stay signed in/i,
87+
});
88+
await user.click(stayBtn);
89+
expect(signOutSpy).not.toHaveBeenCalled();
90+
await waitFor(() =>
91+
expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-hidden"),
92+
);
93+
jest.clearAllMocks();
94+
});
95+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"use client";
2+
3+
import { signOut, useSession } from "next-auth/react";
4+
import { useIdleTimer } from "react-idle-timer";
5+
import type { ModalProps, ModalRef } from "../../designSystem/modal/Modal";
6+
import { useEffect, useRef, useState } from "react";
7+
import dynamic from "next/dynamic";
8+
import { PAGES } from "@/app/shared/page-routes";
9+
10+
const Modal = dynamic<ModalProps>(
11+
() => import("../../designSystem/modal/Modal").then((mod) => mod.Modal),
12+
{ ssr: false },
13+
);
14+
15+
// 60 mins inactivity
16+
export const IDLE_TIMEOUT_MSEC = 60 * 60000;
17+
// 5 mins to answer prompt / 5 mins before timeout
18+
export const PROMPT_TIMEOUT_MSEC = 5 * 60000;
19+
20+
export interface SessionTimeoutProps {
21+
idleTimeMsec: number;
22+
promptTimeMsec: number;
23+
}
24+
25+
/**
26+
* @param root0 Props of SessionTimeout component
27+
* @param root0.idleTimeMsec amout of time that the user needs to be idle in order to trigger automatic signout
28+
* @param root0.promptTimeMsec amount of time that the prompt will be displayed to a user. This time uses as reference the idle timeout
29+
* for example if it is set to 5 mins then the prompt will become visible 5 mins before the idle time has been reached.
30+
* @returns SessionTimeout component which handles tracking idle time for a logged in user.
31+
*/
32+
const SessionTimeout: React.FC<SessionTimeoutProps> = ({
33+
idleTimeMsec,
34+
promptTimeMsec,
35+
}) => {
36+
const { status } = useSession();
37+
const [remainingTime, setRemainingTime] = useState("");
38+
const intervalId = useRef<NodeJS.Timeout | null>(null);
39+
const [started, setStarted] = useState(false);
40+
const modalRef = useRef<ModalRef>(null);
41+
42+
const { activate, start, reset, pause, getRemainingTime } = useIdleTimer({
43+
timeout: idleTimeMsec,
44+
promptBeforeIdle: promptTimeMsec,
45+
onPrompt: handlePrompt,
46+
onIdle: handleLogout,
47+
stopOnIdle: true,
48+
startManually: true,
49+
disabled: status !== "authenticated",
50+
});
51+
52+
function handlePrompt() {
53+
intervalId.current = setInterval(() => {
54+
const msecs = getRemainingTime();
55+
const mins = Math.floor(msecs / 60000);
56+
const secs = Math.floor((msecs / 1000) % 60);
57+
58+
setRemainingTime(
59+
`${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`,
60+
);
61+
}, 500);
62+
63+
modalRef.current?.toggleModal();
64+
}
65+
66+
async function handleLogout() {
67+
if (intervalId.current !== null) {
68+
clearInterval(intervalId.current);
69+
}
70+
71+
modalRef.current?.modalIsOpen && modalRef.current?.toggleModal();
72+
//stop timer and logout
73+
reset();
74+
pause();
75+
await signOut({ redirectTo: PAGES.LANDING });
76+
}
77+
78+
function handleStay() {
79+
modalRef.current?.modalIsOpen && modalRef.current?.toggleModal();
80+
activate();
81+
}
82+
83+
// Inititalize timer
84+
useEffect(() => {
85+
if (!started && status === "authenticated") {
86+
setStarted(true);
87+
start();
88+
}
89+
}, [status, started, setStarted, start]);
90+
91+
return (
92+
<Modal
93+
id="session-timeout"
94+
modalRef={modalRef}
95+
heading={`Your session will end in ${remainingTime}`}
96+
forceAction
97+
className={"width-auto padding-x-3"}
98+
buttons={[
99+
{
100+
text: "Yes, stay signed in",
101+
type: "button" as const,
102+
id: "session-timeout-stayin",
103+
className: "usa-button",
104+
onClick: handleStay,
105+
},
106+
{
107+
text: "Sign out",
108+
type: "button" as const,
109+
id: "session-timeout-signout",
110+
className: "usa-button usa-button--outline",
111+
onClick: handleLogout,
112+
},
113+
]}
114+
>
115+
<p>
116+
You've been inactive for over 55 minutes.
117+
<br />
118+
Do you wish to stay signed in?
119+
</p>
120+
</Modal>
121+
);
122+
};
123+
124+
export default SessionTimeout;

query-connector/src/app/ui/designSystem/modal/Modal.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import {
24
Modal as TrussModal,
35
ModalHeading,
@@ -7,6 +9,7 @@ import {
79
ModalRef as TrussModalRef,
810
Icon,
911
} from "@trussworks/react-uswds";
12+
import classNames from "classnames";
1013
import React, { RefObject, ReactNode } from "react";
1114

1215
export type ModalRef = TrussModalRef;
@@ -28,6 +31,8 @@ export type ModalProps = {
2831
buttons: ModalButton[];
2932
isLarge?: boolean;
3033
errorMessage?: string | null; // New prop for error message
34+
forceAction?: boolean;
35+
className?: string;
3136
};
3237

3338
/**
@@ -41,6 +46,8 @@ export type ModalProps = {
4146
* @param props.buttons - The buttons to display in the footer.
4247
* @param props.isLarge - Whether the modal is large.
4348
* @param props.errorMessage - The error message to display in the footer.
49+
* @param props.forceAction - when true the user cannot dismiss the modal unless an specific action is made.
50+
* @param props.className additional classes that can be applied to the modal. The classes will be set to the most outer div element.
4451
* @returns - A customizable modal component
4552
*/
4653
export const Modal: React.FC<ModalProps> = ({
@@ -52,6 +59,8 @@ export const Modal: React.FC<ModalProps> = ({
5259
buttons,
5360
isLarge,
5461
errorMessage,
62+
forceAction,
63+
className,
5564
}) => {
5665
return (
5766
<TrussModal
@@ -60,7 +69,8 @@ export const Modal: React.FC<ModalProps> = ({
6069
aria-labelledby={`${id}-modal-heading`}
6170
aria-describedby={`${id}-modal-description`}
6271
isLarge={isLarge}
63-
className="padding-x-2"
72+
forceAction={forceAction}
73+
className={classNames("padding-x-2", className)}
6474
>
6575
<ModalHeading id={`${id}-modal-heading`}>{heading}</ModalHeading>
6676
<div id={`${id}-modal-description`} className="usa-prose">

0 commit comments

Comments
 (0)