diff --git a/query-connector/__mocks__/next-auth/react.js b/query-connector/__mocks__/next-auth/react.js
index 9e2d6e436..bc229751a 100644
--- a/query-connector/__mocks__/next-auth/react.js
+++ b/query-connector/__mocks__/next-auth/react.js
@@ -4,6 +4,7 @@ const useSession = jest
.fn()
.mockReturnValue({ data: undefined, status: "loading" });
+const signOut = jest.fn();
/**
*
* @param root0
@@ -11,4 +12,4 @@ const useSession = jest
*/
const SessionProvider = ({ children }) => <>{children}>;
-export { useSession, SessionProvider };
+export { useSession, signOut, SessionProvider };
diff --git a/query-connector/__mocks__/tabbable.js b/query-connector/__mocks__/tabbable.js
new file mode 100644
index 000000000..ff8e2e8e7
--- /dev/null
+++ b/query-connector/__mocks__/tabbable.js
@@ -0,0 +1,38 @@
+/* eslint-disable jsdoc/require-returns, jsdoc/require-param-description */
+// __mocks__/tabbable.js
+
+const lib = jest.requireActual("tabbable");
+
+const tabbable = {
+ ...lib,
+ /**
+ *
+ * @param node
+ * @param options
+ */
+ tabbable: (node, options) =>
+ lib.tabbable(node, { ...options, displayCheck: "none" }),
+ /**
+ *
+ * @param node
+ * @param options
+ */
+ focusable: (node, options) =>
+ lib.focusable(node, { ...options, displayCheck: "none" }),
+ /**
+ *
+ * @param node
+ * @param options
+ */
+ isFocusable: (node, options) =>
+ lib.isFocusable(node, { ...options, displayCheck: "none" }),
+ /**
+ *
+ * @param node
+ * @param options
+ */
+ isTabbable: (node, options) =>
+ lib.isTabbable(node, { ...options, displayCheck: "none" }),
+};
+
+module.exports = tabbable;
diff --git a/query-connector/package-lock.json b/query-connector/package-lock.json
index e3f3e42ef..0b1487e56 100644
--- a/query-connector/package-lock.json
+++ b/query-connector/package-lock.json
@@ -28,6 +28,7 @@
"react": "^18",
"react-dom": "^18",
"react-highlight-words": "^0.21.0",
+ "react-idle-timer": "^5.7.2",
"react-loading-skeleton": "^3.5.0",
"react-toastify": "^10.0.5",
"sharp": "^0.33.5"
@@ -13460,6 +13461,15 @@
"react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
+ "node_modules/react-idle-timer": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.7.2.tgz",
+ "integrity": "sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==",
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
diff --git a/query-connector/package.json b/query-connector/package.json
index 7f972089a..7c8e26270 100644
--- a/query-connector/package.json
+++ b/query-connector/package.json
@@ -41,6 +41,7 @@
"react": "^18",
"react-dom": "^18",
"react-highlight-words": "^0.21.0",
+ "react-idle-timer": "^5.7.2",
"react-loading-skeleton": "^3.5.0",
"react-toastify": "^10.0.5",
"sharp": "^0.33.5"
diff --git a/query-connector/src/app/layout.tsx b/query-connector/src/app/layout.tsx
index e4bd1541b..79682e99f 100644
--- a/query-connector/src/app/layout.tsx
+++ b/query-connector/src/app/layout.tsx
@@ -7,6 +7,10 @@ import { Metadata } from "next";
import Page from "./ui/components/page/page";
import { auth } from "@/auth";
import { isAuthDisabledServerCheck } from "./utils/auth";
+import SessionTimeout, {
+ IDLE_TIMEOUT_MSEC,
+ PROMPT_TIMEOUT_MSEC,
+} from "./ui/components/sessionTimeout/sessionTimeout";
/**
* Establishes the layout for the application.
@@ -32,6 +36,10 @@ export default async function RootLayout({
+
{children}
diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
new file mode 100644
index 000000000..4e7f819be
--- /dev/null
+++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
@@ -0,0 +1,95 @@
+import { renderWithUser, RootProviderMock } from "@/app/tests/unit/setup";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { SessionContextValue } from "next-auth/react";
+import * as nextAuthReact from "next-auth/react";
+import SessionTimeout from "./sessionTimeout";
+import { UserRole } from "@/app/models/entities/users";
+import { Session } from "next-auth";
+import { PAGES } from "@/app/shared/page-routes";
+
+jest.mock("next-auth/react");
+jest.mock("tabbable");
+
+describe("SessionTimeout", () => {
+ it("Does not display when user is unauthenticated", async () => {
+ const session: SessionContextValue = {
+ data: null,
+ status: "unauthenticated",
+ update: jest.fn(),
+ };
+
+ jest.spyOn(nextAuthReact, "useSession").mockReturnValueOnce(session);
+
+ render(
+
+
+ ,
+ );
+
+ // give opportunity for a potential modal to display
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ });
+
+ const dialog = screen.getByRole("dialog", { hidden: true });
+ expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-hidden");
+ });
+
+ it("Display when user is authenticated", async () => {
+ const session: SessionContextValue = {
+ data: { user: { role: UserRole.STANDARD } } as Session,
+ status: "authenticated",
+ update: jest.fn(),
+ };
+
+ jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
+ const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
+
+ render(
+
+
+ ,
+ );
+
+ const dialog = screen.getByRole("dialog", { hidden: true });
+ await waitFor(() =>
+ expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-visible"),
+ );
+
+ await waitFor(() =>
+ expect(signOutSpy).toHaveBeenCalledWith({ redirectTo: PAGES.LANDING }),
+ );
+ jest.clearAllMocks();
+ });
+
+ it("Resets idle timer if user decides to stay", async () => {
+ const session: SessionContextValue = {
+ data: { user: { role: UserRole.STANDARD } } as Session,
+ status: "authenticated",
+ update: jest.fn(),
+ };
+
+ jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
+ const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
+
+ const { user } = renderWithUser(
+
+
+ ,
+ );
+
+ const dialog = screen.getByRole("dialog", { hidden: true });
+ await waitFor(() =>
+ expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-visible"),
+ );
+ const stayBtn = screen.getByRole("button", {
+ name: /yes, stay signed in/i,
+ });
+ await user.click(stayBtn);
+ expect(signOutSpy).not.toHaveBeenCalled();
+ await waitFor(() =>
+ expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-hidden"),
+ );
+ jest.clearAllMocks();
+ });
+});
diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
new file mode 100644
index 000000000..3cf1429db
--- /dev/null
+++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { signOut, useSession } from "next-auth/react";
+import { useIdleTimer } from "react-idle-timer";
+import type { ModalProps, ModalRef } from "../../designSystem/modal/Modal";
+import { useEffect, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import { PAGES } from "@/app/shared/page-routes";
+
+const Modal = dynamic(
+ () => import("../../designSystem/modal/Modal").then((mod) => mod.Modal),
+ { ssr: false },
+);
+
+// 60 mins inactivity
+export const IDLE_TIMEOUT_MSEC = 60 * 60000;
+// 5 mins to answer prompt / 5 mins before timeout
+export const PROMPT_TIMEOUT_MSEC = 5 * 60000;
+
+export interface SessionTimeoutProps {
+ idleTimeMsec: number;
+ promptTimeMsec: number;
+}
+
+/**
+ * @param root0 Props of SessionTimeout component
+ * @param root0.idleTimeMsec amout of time that the user needs to be idle in order to trigger automatic signout
+ * @param root0.promptTimeMsec amount of time that the prompt will be displayed to a user. This time uses as reference the idle timeout
+ * for example if it is set to 5 mins then the prompt will become visible 5 mins before the idle time has been reached.
+ * @returns SessionTimeout component which handles tracking idle time for a logged in user.
+ */
+const SessionTimeout: React.FC = ({
+ idleTimeMsec,
+ promptTimeMsec,
+}) => {
+ const { status } = useSession();
+ const [remainingTime, setRemainingTime] = useState("");
+ const intervalId = useRef(null);
+ const [started, setStarted] = useState(false);
+ const modalRef = useRef(null);
+
+ const { activate, start, reset, pause, getRemainingTime } = useIdleTimer({
+ timeout: idleTimeMsec,
+ promptBeforeIdle: promptTimeMsec,
+ onPrompt: handlePrompt,
+ onIdle: handleLogout,
+ stopOnIdle: true,
+ startManually: true,
+ disabled: status !== "authenticated",
+ });
+
+ function handlePrompt() {
+ intervalId.current = setInterval(() => {
+ const msecs = getRemainingTime();
+ const mins = Math.floor(msecs / 60000);
+ const secs = Math.floor((msecs / 1000) % 60);
+
+ setRemainingTime(
+ `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`,
+ );
+ }, 500);
+
+ modalRef.current?.toggleModal();
+ }
+
+ async function handleLogout() {
+ if (intervalId.current !== null) {
+ clearInterval(intervalId.current);
+ }
+
+ modalRef.current?.modalIsOpen && modalRef.current?.toggleModal();
+ //stop timer and logout
+ reset();
+ pause();
+ await signOut({ redirectTo: PAGES.LANDING });
+ }
+
+ function handleStay() {
+ modalRef.current?.modalIsOpen && modalRef.current?.toggleModal();
+ activate();
+ }
+
+ // Inititalize timer
+ useEffect(() => {
+ if (!started && status === "authenticated") {
+ setStarted(true);
+ start();
+ }
+ }, [status, started, setStarted, start]);
+
+ return (
+
+
+ You've been inactive for over 55 minutes.
+
+ Do you wish to stay signed in?
+
+
+ );
+};
+
+export default SessionTimeout;
diff --git a/query-connector/src/app/ui/designSystem/modal/Modal.tsx b/query-connector/src/app/ui/designSystem/modal/Modal.tsx
index 0d9f27631..8612bcb94 100644
--- a/query-connector/src/app/ui/designSystem/modal/Modal.tsx
+++ b/query-connector/src/app/ui/designSystem/modal/Modal.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import {
Modal as TrussModal,
ModalHeading,
@@ -7,6 +9,7 @@ import {
ModalRef as TrussModalRef,
Icon,
} from "@trussworks/react-uswds";
+import classNames from "classnames";
import React, { RefObject, ReactNode } from "react";
export type ModalRef = TrussModalRef;
@@ -28,6 +31,8 @@ export type ModalProps = {
buttons: ModalButton[];
isLarge?: boolean;
errorMessage?: string | null; // New prop for error message
+ forceAction?: boolean;
+ className?: string;
};
/**
@@ -41,6 +46,8 @@ export type ModalProps = {
* @param props.buttons - The buttons to display in the footer.
* @param props.isLarge - Whether the modal is large.
* @param props.errorMessage - The error message to display in the footer.
+ * @param props.forceAction - when true the user cannot dismiss the modal unless an specific action is made.
+ * @param props.className additional classes that can be applied to the modal. The classes will be set to the most outer div element.
* @returns - A customizable modal component
*/
export const Modal: React.FC = ({
@@ -52,6 +59,8 @@ export const Modal: React.FC = ({
buttons,
isLarge,
errorMessage,
+ forceAction,
+ className,
}) => {
return (
= ({
aria-labelledby={`${id}-modal-heading`}
aria-describedby={`${id}-modal-description`}
isLarge={isLarge}
- className="padding-x-2"
+ forceAction={forceAction}
+ className={classNames("padding-x-2", className)}
>
{heading}