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}