-
Notifications
You must be signed in to change notification settings - Fork 5
jd/implements automatic logout modal #440
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
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
5bc8da0
Setup session refresh in session provider
johanna-skylight b3a1e66
fixed refresh time interval to be in secs
johanna-skylight 80afdd4
First draft of the timeout modal
johanna-skylight 1517829
Implemented session timeout modal
johanna-skylight e4ed986
Merge remote-tracking branch 'origin/main' into jb/session_mgt_logout…
johanna-skylight b404e18
Implemented UI changes
johanna-skylight 8a72613
Implemented timeout
johanna-skylight ca063c7
Merge remote-tracking branch 'origin/main' into jb/session_mgt_logout…
johanna-skylight da09930
Implemented unit test for sessionTimeout component
johanna-skylight 3f67b0d
Merge branch 'main' into jb/session_mgt_logout_modal
johanna-skylight 20e9965
update to copy
johanna-skylight a6aa880
Merge branch 'main' into jb/session_mgt_logout_modal
johanna-skylight 49149ff
Merge branch 'main' into jb/session_mgt_logout_modal
johanna-skylight 4e69954
Addresses UI feedback
johanna-skylight File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
124 changes: 124 additions & 0 deletions
124
query-connector/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.