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

jd/implements automatic logout modal #440

Merged
merged 14 commits into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion query-connector/__mocks__/next-auth/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ const useSession = jest
.fn()
.mockReturnValue({ data: undefined, status: "loading" });

const signOut = jest.fn();
/**
*
* @param root0
* @param root0.children
*/
const SessionProvider = ({ children }) => <>{children}</>;

export { useSession, SessionProvider };
export { useSession, signOut, SessionProvider };
38 changes: 38 additions & 0 deletions query-connector/__mocks__/tabbable.js
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions query-connector/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions query-connector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions query-connector/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +36,10 @@ export default async function RootLayout({
<body>
<div className="application-container">
<DataProvider runtimeConfig={runtimeConfig} session={session}>
<SessionTimeout
idleTimeMsec={IDLE_TIMEOUT_MSEC}
promptTimeMsec={PROMPT_TIMEOUT_MSEC}
/>
<Header authDisabled={isAuthDisabledServerCheck()} />
<Page showSiteAlert={process.env.DEMO_MODE === "true"}>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<RootProviderMock currentPage={"/"}>
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
</RootProviderMock>,
);

// 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(
<RootProviderMock currentPage={"/"}>
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
</RootProviderMock>,
);

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(
<RootProviderMock currentPage={"/"}>
<SessionTimeout idleTimeMsec={1000} promptTimeMsec={500} />
</RootProviderMock>,
);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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<ModalProps>(
() => 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<SessionTimeoutProps> = ({
idleTimeMsec,
promptTimeMsec,
}) => {
const { status } = useSession();
const [remainingTime, setRemainingTime] = useState("");
const intervalId = useRef<NodeJS.Timeout | null>(null);
const [started, setStarted] = useState(false);
const modalRef = useRef<ModalRef>(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 (
<Modal
id="session-timeout"
modalRef={modalRef}
heading={`Your session will end in ${remainingTime}`}
forceAction
className={"width-auto padding-x-3"}
buttons={[
{
text: "Yes, stay signed in",
type: "button" as const,
id: "session-timeout-stayin",
className: "usa-button",
onClick: handleStay,
},
{
text: "Sign out",
type: "button" as const,
id: "session-timeout-signout",
className: "usa-button usa-button--outline",
onClick: handleLogout,
},
]}
>
<p>
You've been inactive for over 55 minutes.
<br />
Do you wish to stay signed in?
</p>
</Modal>
);
};

export default SessionTimeout;
12 changes: 11 additions & 1 deletion query-connector/src/app/ui/designSystem/modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import {
Modal as TrussModal,
ModalHeading,
Expand All @@ -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;
Expand All @@ -28,6 +31,8 @@ export type ModalProps = {
buttons: ModalButton[];
isLarge?: boolean;
errorMessage?: string | null; // New prop for error message
forceAction?: boolean;
className?: string;
};

/**
Expand All @@ -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<ModalProps> = ({
Expand All @@ -52,6 +59,8 @@ export const Modal: React.FC<ModalProps> = ({
buttons,
isLarge,
errorMessage,
forceAction,
className,
}) => {
return (
<TrussModal
Expand All @@ -60,7 +69,8 @@ export const Modal: React.FC<ModalProps> = ({
aria-labelledby={`${id}-modal-heading`}
aria-describedby={`${id}-modal-description`}
isLarge={isLarge}
className="padding-x-2"
forceAction={forceAction}
className={classNames("padding-x-2", className)}
>
<ModalHeading id={`${id}-modal-heading`}>{heading}</ModalHeading>
<div id={`${id}-modal-description`} className="usa-prose">
Expand Down
Loading