From 5bc8da0ce2cea5e2d20d2359c0f8f2785488e436 Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Thu, 13 Mar 2025 09:21:22 -0400 Subject: [PATCH 1/9] Setup session refresh in session provider --- .../src/app/(pages)/query/page.tsx | 5 +++-- .../src/app/shared/DataProvider.tsx | 9 +++++++- query-connector/src/auth.ts | 21 +++++++++---------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/query-connector/src/app/(pages)/query/page.tsx b/query-connector/src/app/(pages)/query/page.tsx index 3edb69223..651f8abf7 100644 --- a/query-connector/src/app/(pages)/query/page.tsx +++ b/query-connector/src/app/(pages)/query/page.tsx @@ -13,6 +13,7 @@ import { DataContext } from "@/app/shared/DataProvider"; import { Patient } from "fhir/r4"; import { getFhirServerNames } from "@/app/backend/dbServices/fhir-servers"; import { CustomUserQuery } from "@/app/models/entities/query"; +import WithAuth from "@/app/ui/components/withAuth/WithAuth"; const blankUserQuery = { query_id: "", @@ -69,7 +70,7 @@ const Query: React.FC = () => { results: "main-container__wide", }; return ( - <> + {Object.keys(CUSTOMIZE_QUERY_STEPS).includes(mode) && !showCustomizeQuery && ( { /> )} - + ); }; diff --git a/query-connector/src/app/shared/DataProvider.tsx b/query-connector/src/app/shared/DataProvider.tsx index e64623f73..2fb7954f5 100644 --- a/query-connector/src/app/shared/DataProvider.tsx +++ b/query-connector/src/app/shared/DataProvider.tsx @@ -6,6 +6,8 @@ import { PageType } from "./constants"; import { ToastConfigOptions } from "../ui/designSystem/toast/Toast"; import { Session } from "next-auth"; +const REFRESH_INTERVAL_MINS = 15; + export interface DataContextValue { data: unknown; // You can define a specific data type here setData: (data: unknown) => void; @@ -58,7 +60,12 @@ export function DataProvider({ runtimeConfig, }} > - {children} + + {children} + ); } diff --git a/query-connector/src/auth.ts b/query-connector/src/auth.ts index bf911c781..9b6cc4ab3 100644 --- a/query-connector/src/auth.ts +++ b/query-connector/src/auth.ts @@ -67,20 +67,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ console.error("Something went wrong in generating user token", error); } - if (userToken.username !== "") { - if (isAuthDisabledServerCheck()) { - userToken.role = UserRole.SUPER_ADMIN; - } else { - const role = await getUserRole( - userToken.username as string, - ).catch(); - userToken.role = role; - } - } - return { ...token, ...userToken }; } + if (token.username !== "") { + if (isAuthDisabledServerCheck()) { + token.role = UserRole.SUPER_ADMIN; + } else { + const role = await getUserRole(token.username as string).catch(); + token.role = role; + } + } + return token; }, @@ -101,6 +99,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ emailVerified: null, role: typeof token.role === "string" ? token.role : "", }; + return session; }, }, From b3a1e66008d5b8d26e6504c91c2cebf0ca44e243 Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Thu, 13 Mar 2025 09:28:46 -0400 Subject: [PATCH 2/9] fixed refresh time interval to be in secs --- query-connector/src/app/shared/DataProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query-connector/src/app/shared/DataProvider.tsx b/query-connector/src/app/shared/DataProvider.tsx index 2fb7954f5..1642647b6 100644 --- a/query-connector/src/app/shared/DataProvider.tsx +++ b/query-connector/src/app/shared/DataProvider.tsx @@ -62,7 +62,7 @@ export function DataProvider({ > {children} From 80afdd4eb05138375df37eab24ef525428d7b5b6 Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Thu, 13 Mar 2025 10:53:05 -0400 Subject: [PATCH 3/9] First draft of the timeout modal --- query-connector/package-lock.json | 10 +++ query-connector/package.json | 1 + query-connector/src/app/layout.tsx | 2 + .../sessionTimeout/sessionTimeout.tsx | 80 +++++++++++++++++++ .../src/app/ui/designSystem/modal/Modal.tsx | 4 + 5 files changed, 97 insertions(+) create mode 100644 query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx diff --git a/query-connector/package-lock.json b/query-connector/package-lock.json index 5c69f9be7..63b8d11fe 100644 --- a/query-connector/package-lock.json +++ b/query-connector/package-lock.json @@ -29,6 +29,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" @@ -13432,6 +13433,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 e7a34bd24..1d16e4b51 100644 --- a/query-connector/package.json +++ b/query-connector/package.json @@ -44,6 +44,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..27f758498 100644 --- a/query-connector/src/app/layout.tsx +++ b/query-connector/src/app/layout.tsx @@ -7,6 +7,7 @@ import { Metadata } from "next"; import Page from "./ui/components/page/page"; import { auth } from "@/auth"; import { isAuthDisabledServerCheck } from "./utils/auth"; +import SessionTimeout from "./ui/components/sessionTimeout/sessionTimeout"; /** * Establishes the layout for the application. @@ -32,6 +33,7 @@ export default async function RootLayout({
+
{children} 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..2c380a9b1 --- /dev/null +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { signOut, useSession } from "next-auth/react"; +import { useIdleTimer } from "react-idle-timer"; +import { Modal, ModalRef } from "../../designSystem/modal/Modal"; +import { useRef } from "react"; + +const IDLE_TIMEOUT = 60; + +/** + * @returns SessionTimeout component which handles tracking idle time for a logged in user. + */ +const SessionTimeout: React.FC = () => { + const { status } = useSession(); + const modalRef = useRef(null); + const { start, reset, getRemainingTime } = useIdleTimer({ + timeout: IDLE_TIMEOUT * 120000, + onIdle, + stopOnIdle: true, + startManually: true, + }); + + if (status == "authenticated") { + start(); + } + + async function logout() { + await signOut({ redirectTo: "/" }); + } + + function onIdle() { + console.log("on idle"); + modalRef.current?.toggleModal(); + + /* const id = setTimeout(()=>{ + modalRef.current?.toggleModal(); + logout().then(); + },10000); +*/ + } + + function handleStay() { + console.log("on stay"); + // clearTimeout(countDown.current); + modalRef.current?.toggleModal(); + reset(); + } + + console.log(getRemainingTime()); + + return ( + + You've been inactive for too long. Please choose to stay signed in or + signout. Otherwise, you will be signed out automatically in 5 minutes. + + ); +}; + +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..c0aea125c 100644 --- a/query-connector/src/app/ui/designSystem/modal/Modal.tsx +++ b/query-connector/src/app/ui/designSystem/modal/Modal.tsx @@ -28,6 +28,7 @@ export type ModalProps = { buttons: ModalButton[]; isLarge?: boolean; errorMessage?: string | null; // New prop for error message + forceAction?: boolean; }; /** @@ -41,6 +42,7 @@ 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. * @returns - A customizable modal component */ export const Modal: React.FC = ({ @@ -52,6 +54,7 @@ export const Modal: React.FC = ({ buttons, isLarge, errorMessage, + forceAction, }) => { return ( = ({ aria-labelledby={`${id}-modal-heading`} aria-describedby={`${id}-modal-description`} isLarge={isLarge} + forceAction={forceAction} className="padding-x-2" > {heading} From 15178296964a3eb6ae4abb70079a27d9e328333a Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Fri, 14 Mar 2025 15:52:37 -0400 Subject: [PATCH 4/9] Implemented session timeout modal --- .../sessionTimeout/sessionTimeout.tsx | 68 +++++++++++-------- .../src/app/ui/designSystem/modal/Modal.tsx | 2 + 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx index 2c380a9b1..6ec726c00 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -2,51 +2,65 @@ import { signOut, useSession } from "next-auth/react"; import { useIdleTimer } from "react-idle-timer"; -import { Modal, ModalRef } from "../../designSystem/modal/Modal"; -import { useRef } from "react"; +import type { ModalProps, ModalRef } from "../../designSystem/modal/Modal"; +import { useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; -const IDLE_TIMEOUT = 60; +const Modal = dynamic( + () => import("../../designSystem/modal/Modal").then((mod) => mod.Modal), + { ssr: false }, +); + +// 60 mins inactivity +const IDLE_TIMEOUT_MSEC = 60 * 60000; +// 5 mins to answer prompt / 5 mins before timeout +const PROMPT_TIMEOUT_MSEC = 5 * 60000; /** * @returns SessionTimeout component which handles tracking idle time for a logged in user. */ const SessionTimeout: React.FC = () => { const { status } = useSession(); + + if (status == "unauthenticated") { + return null; + } + + const [started, setStarted] = useState(false); const modalRef = useRef(null); - const { start, reset, getRemainingTime } = useIdleTimer({ - timeout: IDLE_TIMEOUT * 120000, - onIdle, + const { activate, start, reset, pause, getRemainingTime } = useIdleTimer({ + timeout: IDLE_TIMEOUT_MSEC, + promptBeforeIdle: PROMPT_TIMEOUT_MSEC, + onPrompt: handlePrompt, + onIdle: handleLogout, stopOnIdle: true, startManually: true, }); - if (status == "authenticated") { - start(); - } - - async function logout() { - await signOut({ redirectTo: "/" }); - } - - function onIdle() { - console.log("on idle"); + function handlePrompt() { modalRef.current?.toggleModal(); + } - /* const id = setTimeout(()=>{ - modalRef.current?.toggleModal(); - logout().then(); - },10000); -*/ + async function handleLogout() { + modalRef.current?.modalIsOpen && modalRef.current?.toggleModal(); + //stop timer and logout + reset(); + pause(); + await signOut({ redirect: false }); // TODO tests this again } function handleStay() { - console.log("on stay"); - // clearTimeout(countDown.current); - modalRef.current?.toggleModal(); - reset(); + modalRef.current?.modalIsOpen && modalRef.current?.toggleModal(); + activate(); } - console.log(getRemainingTime()); + // Inititalize timer + useEffect(() => { + if (!started && status === "authenticated") { + setStarted(true); + start(); + } + }, [status, started, setStarted]); return ( { type: "button" as const, id: "session-timeout-signout", className: "usa-button usa-button--outline", - onClick: logout, + onClick: handleLogout, }, ]} > diff --git a/query-connector/src/app/ui/designSystem/modal/Modal.tsx b/query-connector/src/app/ui/designSystem/modal/Modal.tsx index c0aea125c..abc1473f2 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, From b404e1855eb84bc1c25ea640b5626134fbaa4466 Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Mon, 17 Mar 2025 13:13:33 -0400 Subject: [PATCH 5/9] Implemented UI changes --- .../sessionTimeout/sessionTimeout.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx index 6ec726c00..8608d4889 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -5,6 +5,7 @@ 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), @@ -26,6 +27,8 @@ const SessionTimeout: React.FC = () => { return null; } + const [remainingTime, setRemainingTime] = useState(""); + const intervalId = useRef(null); const [started, setStarted] = useState(false); const modalRef = useRef(null); const { activate, start, reset, pause, getRemainingTime } = useIdleTimer({ @@ -38,15 +41,29 @@ const SessionTimeout: React.FC = () => { }); 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({ redirect: false }); // TODO tests this again + await signOut({ redirectTo: PAGES.LANDING }); } function handleStay() { @@ -60,13 +77,13 @@ const SessionTimeout: React.FC = () => { setStarted(true); start(); } - }, [status, started, setStarted]); + }, [status, started, setStarted, start]); return ( { }, ]} > - You've been inactive for too long. Please choose to stay signed in or - signout. Otherwise, you will be signed out automatically in 5 minutes. + You've been inactive for over 55 minutes. Do you wish to stay signed in? ); }; From 8a72613af7861e9d6b249f03a170dda956290050 Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Tue, 18 Mar 2025 09:05:22 -0400 Subject: [PATCH 6/9] Implemented timeout --- .../app/ui/components/sessionTimeout/sessionTimeout.test.tsx | 0 .../src/app/ui/components/sessionTimeout/sessionTimeout.tsx | 5 +---- 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx 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..e69de29bb diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx index 8608d4889..0136fcc54 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -23,10 +23,6 @@ const PROMPT_TIMEOUT_MSEC = 5 * 60000; const SessionTimeout: React.FC = () => { const { status } = useSession(); - if (status == "unauthenticated") { - return null; - } - const [remainingTime, setRemainingTime] = useState(""); const intervalId = useRef(null); const [started, setStarted] = useState(false); @@ -38,6 +34,7 @@ const SessionTimeout: React.FC = () => { onIdle: handleLogout, stopOnIdle: true, startManually: true, + disabled: status !== "authenticated", }); function handlePrompt() { From da0993050b60748d098e871d0fbbab31965170eb Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Tue, 18 Mar 2025 10:55:11 -0400 Subject: [PATCH 7/9] Implemented unit test for sessionTimeout component --- query-connector/__mocks__/next-auth/react.js | 3 +- query-connector/__mocks__/tabbable.js | 38 ++++++++ query-connector/src/app/layout.tsx | 10 +- .../sessionTimeout/sessionTimeout.test.tsx | 95 +++++++++++++++++++ .../sessionTimeout/sessionTimeout.tsx | 24 +++-- 5 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 query-connector/__mocks__/tabbable.js 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/src/app/layout.tsx b/query-connector/src/app/layout.tsx index 27f758498..79682e99f 100644 --- a/query-connector/src/app/layout.tsx +++ b/query-connector/src/app/layout.tsx @@ -7,7 +7,10 @@ import { Metadata } from "next"; import Page from "./ui/components/page/page"; import { auth } from "@/auth"; import { isAuthDisabledServerCheck } from "./utils/auth"; -import SessionTimeout from "./ui/components/sessionTimeout/sessionTimeout"; +import SessionTimeout, { + IDLE_TIMEOUT_MSEC, + PROMPT_TIMEOUT_MSEC, +} from "./ui/components/sessionTimeout/sessionTimeout"; /** * Establishes the layout for the application. @@ -33,7 +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 index e69de29bb..4e7f819be 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx +++ 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 index 0136fcc54..7a071f0fd 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -13,23 +13,35 @@ const Modal = dynamic( ); // 60 mins inactivity -const IDLE_TIMEOUT_MSEC = 60 * 60000; +export const IDLE_TIMEOUT_MSEC = 60 * 60000; // 5 mins to answer prompt / 5 mins before timeout -const PROMPT_TIMEOUT_MSEC = 5 * 60000; +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 = () => { +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: IDLE_TIMEOUT_MSEC, - promptBeforeIdle: PROMPT_TIMEOUT_MSEC, + timeout: idleTimeMsec, + promptBeforeIdle: promptTimeMsec, onPrompt: handlePrompt, onIdle: handleLogout, stopOnIdle: true, From 20e9965579dfea9981184c8c2640170000550cca Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Wed, 19 Mar 2025 14:41:57 -0400 Subject: [PATCH 8/9] update to copy --- .../src/app/ui/components/sessionTimeout/sessionTimeout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx index 7a071f0fd..0d6f3e948 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -111,7 +111,11 @@ const SessionTimeout: React.FC = ({ }, ]} > - You've been inactive for over 55 minutes. Do you wish to stay signed in? +

+ You've been inactive for over 55 minutes. +
+ Do you wish to stay signed in? +

); }; From 4e6995404d0ca06df23e51832b263a70d01bb0aa Mon Sep 17 00:00:00 2001 From: johanna-skylight Date: Thu, 20 Mar 2025 09:42:34 -0400 Subject: [PATCH 9/9] Addresses UI feedback --- .../src/app/ui/components/sessionTimeout/sessionTimeout.tsx | 1 + query-connector/src/app/ui/designSystem/modal/Modal.tsx | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx index 0d6f3e948..3cf1429db 100644 --- a/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx +++ b/query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx @@ -94,6 +94,7 @@ const SessionTimeout: React.FC = ({ modalRef={modalRef} heading={`Your session will end in ${remainingTime}`} forceAction + className={"width-auto padding-x-3"} buttons={[ { text: "Yes, stay signed in", diff --git a/query-connector/src/app/ui/designSystem/modal/Modal.tsx b/query-connector/src/app/ui/designSystem/modal/Modal.tsx index abc1473f2..8612bcb94 100644 --- a/query-connector/src/app/ui/designSystem/modal/Modal.tsx +++ b/query-connector/src/app/ui/designSystem/modal/Modal.tsx @@ -9,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; @@ -31,6 +32,7 @@ export type ModalProps = { isLarge?: boolean; errorMessage?: string | null; // New prop for error message forceAction?: boolean; + className?: string; }; /** @@ -45,6 +47,7 @@ export type ModalProps = { * @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 = ({ @@ -57,6 +60,7 @@ export const Modal: React.FC = ({ isLarge, errorMessage, forceAction, + className, }) => { return ( = ({ aria-describedby={`${id}-modal-description`} isLarge={isLarge} forceAction={forceAction} - className="padding-x-2" + className={classNames("padding-x-2", className)} > {heading}