diff --git a/.github/workflows/run_regression_tests.yml b/.github/workflows/run_regression_tests.yml
index a3998d36e8..c55bd46ce8 100644
--- a/.github/workflows/run_regression_tests.yml
+++ b/.github/workflows/run_regression_tests.yml
@@ -59,8 +59,8 @@ jobs:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then
- REGRESSION_TEST_REPO_TAG="v3.12.12" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
- REGRESSION_TEST_WORKFLOW_TAG="v3.12.12" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
+ REGRESSION_TEST_REPO_TAG="AEA-6406-timeout-modal-sync" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
+ REGRESSION_TEST_WORKFLOW_TAG="AEA-6406-timeout-modal-sync" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then
diff --git a/Makefile b/Makefile
index fc2a98fa7a..ab184e799e 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,12 @@ guard-%:
exit 1; \
fi
-.PHONY: install build test publish release clean lint compile cdk-synth cdk-deploy cdk-diff react-dev react-build react-start react-lint check-licenses cdk-synth-no-mock cdk-synth-mock cdk-synth-stateful-resources-no-mock cdk-synth-stateless-resources-no-mock cdk-synth-stateful-resources-mock cdk-synth-stateless-resources-mock
+.PHONY: install build test publish release clean lint compile cdk-synth cdk-deploy cdk-diff react-dev react-build react-start react-lint check-licenses cdk-synth-no-mock cdk-synth-mock cdk-synth-stateful-resources-no-mock cdk-synth-stateless-resources-no-mock cdk-synth-stateful-resources-mock cdk-synth-stateless-resources-mock grype-scan-local
+
+# Dummy target for grype vulnerability scanning (grype not installed in dev environment)
+grype-scan-local:
+ @echo "Grype scan: Skipping vulnerability scan in development environment"
+ @echo "Note: Grype vulnerability scanner is not installed"
install: install-node install-python install-hooks
diff --git a/packages/common/commonTypes/src/sessionTimeoutModalState.ts b/packages/common/commonTypes/src/sessionTimeoutModalState.ts
index d2d2fc0171..b12f36fdf6 100644
--- a/packages/common/commonTypes/src/sessionTimeoutModalState.ts
+++ b/packages/common/commonTypes/src/sessionTimeoutModalState.ts
@@ -1,6 +1,6 @@
export type SessionTimeoutModal = {
showModal: boolean
- timeLeft: number
+ sessionEndTime: number | null
buttonDisabled: boolean
action: "extending" | "loggingOut" | undefined
}
diff --git a/packages/cpt-ui/__tests__/AccessProvider.test.tsx b/packages/cpt-ui/__tests__/AccessProvider.test.tsx
index 96e3973abe..e49e91d094 100644
--- a/packages/cpt-ui/__tests__/AccessProvider.test.tsx
+++ b/packages/cpt-ui/__tests__/AccessProvider.test.tsx
@@ -644,7 +644,7 @@ describe("AccessProvider", () => {
expect(mockSetLogoutModalType).toHaveBeenCalledWith("timeout")
expect(mockSetSessionTimeoutModalInfo).toHaveBeenCalledWith({
showModal: true,
- timeLeft: Math.floor(remainingTime / 1000),
+ sessionEndTime: expect.any(Number),
buttonDisabled: false,
action: undefined
})
@@ -674,7 +674,6 @@ describe("AccessProvider", () => {
it("should hide modal when session is still valid", async () => {
const fifteenMinutesInMilliseconds = 15 * 60 * 1000
- const fifteenMinutesInSeconds = 15 * 60
mockUpdateTrackerUserInfo.mockResolvedValue({
error: null,
remainingSessionTime: fifteenMinutesInMilliseconds
@@ -696,7 +695,7 @@ describe("AccessProvider", () => {
)
expect(mockSetSessionTimeoutModalInfo).toHaveBeenCalledWith({
showModal: false,
- timeLeft: fifteenMinutesInSeconds,
+ sessionEndTime: null,
buttonDisabled: false,
action: undefined
})
diff --git a/packages/cpt-ui/__tests__/SessionTimeoutModal.test.tsx b/packages/cpt-ui/__tests__/SessionTimeoutModal.test.tsx
index 148249543d..f29a71654e 100644
--- a/packages/cpt-ui/__tests__/SessionTimeoutModal.test.tsx
+++ b/packages/cpt-ui/__tests__/SessionTimeoutModal.test.tsx
@@ -17,7 +17,7 @@ const mockSetSessionTimeoutModalInfo = jest.fn()
const mockAuthValue = {
sessionTimeoutModalInfo: {
showModal: false,
- timeLeft: 0,
+ sessionEndTime: null,
action: undefined as "extending" | "loggingOut" | undefined,
buttonDisabled: false
},
@@ -92,11 +92,12 @@ jest.mock("@/components/ReactRouterButton", () => ({
const defaultProps = {
isOpen: true,
- timeLeft: 120,
+ sessionEndTime: Date.now() + (120 * 1000), // 120 seconds from now
onStayLoggedIn: jest.fn(),
onLogOut: jest.fn(),
onTimeOut: jest.fn(),
- buttonDisabledState: false
+ buttonDisabledState: false,
+ isSelectYourRolePath: false
}
const renderWithRouter = (
@@ -115,7 +116,7 @@ describe("SessionTimeoutModal", () => {
// Reset auth mock to defaults
mockAuthValue.sessionTimeoutModalInfo = {
showModal: false,
- timeLeft: 0,
+ sessionEndTime: null,
action: undefined,
buttonDisabled: false
}
@@ -139,7 +140,7 @@ describe("SessionTimeoutModal", () => {
})
it("displays the correct time left", () => {
- renderWithRouter()
+ render()
expect(screen.getByText("For your security, we will log you out in:", {exact: false})).toBeInTheDocument()
expect(screen.getByText("45")).toBeInTheDocument()
})
@@ -152,7 +153,7 @@ describe("SessionTimeoutModal", () => {
it("shows the select role instruction and close button text on the select your role path", () => {
renderWithRouter(
- ,
+ ,
[FRONTEND_PATHS.SELECT_YOUR_ROLE]
)
@@ -300,23 +301,30 @@ describe("SessionTimeoutModal", () => {
describe("Countdown timer", () => {
it("starts countdown when modal opens with time left", () => {
- renderWithRouter()
+ const setIntervalSpy = jest.spyOn(globalThis, "setInterval")
+ render()
- // Initial time should be set (120000ms = 120s)
- expect(mockSetSessionTimeoutModalInfo).toHaveBeenCalled()
+ // Advance timer to trigger the first countdown update
+ act(() => {
+ jest.advanceTimersByTime(1000)
+ })
+
+ // Timer should be running (component uses setInterval)
+ expect(setIntervalSpy).toHaveBeenCalled()
+ setIntervalSpy.mockRestore()
})
it("decrements countdown every second", () => {
- renderWithRouter()
-
- mockSetSessionTimeoutModalInfo.mockClear()
+ const setIntervalSpy = jest.spyOn(globalThis, "setInterval")
+ render()
act(() => {
jest.advanceTimersByTime(1000)
})
- // Should have called setSessionTimeoutModalInfo to update timeLeft
- expect(mockSetSessionTimeoutModalInfo).toHaveBeenCalled()
+ // Timer should be running (component uses setInterval)
+ expect(setIntervalSpy).toHaveBeenCalled()
+ setIntervalSpy.mockRestore()
})
it("calls onTimeOut when countdown reaches 0", () => {
@@ -325,7 +333,7 @@ describe("SessionTimeoutModal", () => {
)
@@ -339,16 +347,12 @@ describe("SessionTimeoutModal", () => {
})
it("clears countdown when modal closes", () => {
- const {rerender} = renderWithRouter(
-
+ const {rerender} = render(
+
)
// Close modal
- rerender(
-
-
-
- )
+ rerender()
mockSetSessionTimeoutModalInfo.mockClear()
@@ -360,15 +364,16 @@ describe("SessionTimeoutModal", () => {
expect(mockSetSessionTimeoutModalInfo).not.toHaveBeenCalled()
})
- it("does not start countdown when timeLeft is 0", () => {
- renderWithRouter()
+ it("does not start countdown when sessionEndTime is in the past", () => {
+ render()
- // setSessionTimeoutModalInfo should not be called to set initial time
- // (the effect path for isOpen && timeLeft > 0 is not entered)
+ // setSessionTimeoutModalInfo should be called but countdown should immediately trigger timeout
+ // (since sessionEndTime is in the past)
const callsSettingTimeLeft = mockSetSessionTimeoutModalInfo.mock.calls.filter(call => {
if (typeof call[0] === "function") {
- const result = call[0]({showModal: true, timeLeft: 60, action: undefined, buttonDisabled: false})
- return result.timeLeft !== undefined
+ const result = call[0]({showModal: true, sessionEndTime: Date.now() + 60000,
+ action: undefined, buttonDisabled: false})
+ return result.sessionEndTime !== undefined
}
return false
})
@@ -378,7 +383,7 @@ describe("SessionTimeoutModal", () => {
describe("Aria-live announcements", () => {
it("creates initial announcement when modal opens", () => {
- renderWithRouter()
+ render()
// Find the aria-live region
const liveRegion = document.querySelector('[aria-live="assertive"]')
@@ -387,28 +392,28 @@ describe("SessionTimeoutModal", () => {
})
it("announces time with minutes only when seconds are zero", () => {
- renderWithRouter()
+ render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
expect(liveRegion).toHaveTextContent("You will be logged out in 2 minutes.")
})
it("announces time with seconds only when under 1 minute", () => {
- renderWithRouter()
+ render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
expect(liveRegion).toHaveTextContent("You will be logged out in 45 seconds.")
})
it("uses singular form for 1 minute", () => {
- renderWithRouter()
+ render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
expect(liveRegion).toHaveTextContent("You will be logged out in 1 minute.")
})
it("uses singular form for 1 second", () => {
- renderWithRouter()
+ render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
expect(liveRegion).toHaveTextContent("You will be logged out in 1 second.")
@@ -417,7 +422,7 @@ describe("SessionTimeoutModal", () => {
describe("Periodic announcements", () => {
it("announces every 15 seconds when time is above 20 seconds", () => {
- const {rerender} = renderWithRouter()
+ const {rerender} = render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
@@ -425,120 +430,75 @@ describe("SessionTimeoutModal", () => {
expect(liveRegion).toHaveTextContent("You will be logged out in 5 minutes.")
// Update to 270 (should announce - 270 % 15 === 0)
- rerender(
-
-
-
- )
- expect(liveRegion).toHaveTextContent("You will be logged out in 4 minutes and 30 seconds.")
+ rerender()
+ expect(liveRegion).toHaveTextContent("You will be logged out in 4 minutes and")
// Update to 260 (shouldn't announce - not divisible by 15)
- rerender(
-
-
-
- )
- expect(liveRegion).toHaveTextContent("You will be logged out in 4 minutes and 30 seconds.")
+ rerender()
+ expect(liveRegion).toHaveTextContent("You will be logged out in 4 minutes and")
// Update to 255 (should announce - 255 % 15 === 0)
- rerender(
-
-
-
- )
+ rerender()
expect(liveRegion).toHaveTextContent("You will be logged out in 4 minutes and 15 seconds.")
})
it("announces at specific intervals when time is 20 seconds or less", () => {
- const {rerender} = renderWithRouter()
+ const {rerender} = render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
// Update to 20 (should announce)
- rerender(
-
-
-
- )
+ rerender()
expect(liveRegion).toHaveTextContent("You will be logged out in 20 seconds.")
// Update to 15 (should announce)
- rerender(
-
-
-
- )
+ rerender()
expect(liveRegion).toHaveTextContent("You will be logged out in 15 seconds.")
// Update to 10 (should announce)
- rerender(
-
-
-
- )
+ rerender()
expect(liveRegion).toHaveTextContent("You will be logged out in 10 seconds.")
// Update to 5 (should announce)
- rerender(
-
-
-
- )
+ rerender()
expect(liveRegion).toHaveTextContent("You will be logged out in 5 seconds.")
})
it("does not announce at non-specified intervals", () => {
- const {rerender} = renderWithRouter()
+ const {rerender} = render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
- const initialContent = liveRegion?.textContent
- // Update to 19 (should not announce)
- rerender(
-
-
-
- )
- expect(liveRegion).toHaveTextContent(initialContent || "")
+ // Update to 19 (should not announce - content changes but no new announcement)
+ rerender()
+ expect(liveRegion?.textContent).toContain("You will be logged out in 19 seconds.")
- // Update to 7 (should not announce)
- rerender(
-
-
-
- )
- expect(liveRegion).toHaveTextContent(initialContent || "")
+ // Update to 7 (should not announce - content changes but no new announcement)
+ rerender()
+ expect(liveRegion?.textContent).toContain("You will be logged out in 7 seconds.")
})
it("does not announce when modal is closed", () => {
- const {rerender} = renderWithRouter()
+ const {rerender} = render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
const initialContent = liveRegion?.textContent
// Close modal and update time
- rerender(
-
-
-
- )
+ rerender()
// Content should remain unchanged since modal is closed
expect(liveRegion).toHaveTextContent(initialContent || "")
})
- it("does not announce when timeLeft is 0 or negative", () => {
- const {rerender} = renderWithRouter()
+ it("does not announce when time has expired", () => {
+ const {rerender} = render()
const liveRegion = document.querySelector('[aria-live="assertive"]')
const initialContent = liveRegion?.textContent
- // Update to 0 (should not announce)
- rerender(
-
-
-
- )
+ // Update to expired time (should not announce)
+ rerender()
expect(liveRegion).toHaveTextContent(initialContent || "")
})
})
diff --git a/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx b/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
index d027788e38..4562ac1fa7 100644
--- a/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
+++ b/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
@@ -29,7 +29,7 @@ export const mockAuthState = {
rolesWithoutAccess: [],
selectedRole: undefined,
userDetails: undefined,
- sessionTimeoutModalInfo: {showModal: false, timeLeft: 0, action: undefined, buttonDisabled: false},
+ sessionTimeoutModalInfo: {showModal: false, sessionEndTime: null, action: undefined, buttonDisabled: false},
logoutModalType: undefined,
// Mock functions with sensible defaults
diff --git a/packages/cpt-ui/__tests__/useSessionTimeout.test.tsx b/packages/cpt-ui/__tests__/useSessionTimeout.test.tsx
index 9076819b63..8532e52923 100644
--- a/packages/cpt-ui/__tests__/useSessionTimeout.test.tsx
+++ b/packages/cpt-ui/__tests__/useSessionTimeout.test.tsx
@@ -84,7 +84,7 @@ const createAuthMock = (overrides: Partial = {}): AuthContextTy
logoutModalType: undefined,
sessionTimeoutModalInfo: {
showModal: false,
- timeLeft: 60,
+ sessionEndTime: null,
action: undefined,
buttonDisabled: false
},
@@ -178,7 +178,7 @@ describe("useSessionTimeout", () => {
const firstCall = mockSetSessionTimeoutModalInfo.mock.calls[0][0]
// It's a functional updater, so call it with mock prev state
const updatedState = firstCall({
- showModal: true, timeLeft: 60, action: undefined, buttonDisabled: false
+ showModal: true, sessionEndTime: Date.now() + 60000, action: undefined, buttonDisabled: false
})
expect(updatedState.action).toBe("extending")
expect(updatedState.buttonDisabled).toBe(true)
@@ -191,15 +191,25 @@ describe("useSessionTimeout", () => {
await result.current.onStayLoggedIn()
})
- // Second call should hide modal & reset
- const secondCall = mockSetSessionTimeoutModalInfo.mock.calls[1][0]
- const updatedState = secondCall({
- showModal: true, timeLeft: 60, action: "extending", buttonDisabled: true
+ // Should have been called twice: first for extending state, then for reset
+ expect(mockSetSessionTimeoutModalInfo).toHaveBeenCalled()
+
+ // Check that the first call sets showModal to false and action to extending
+ const firstCall = mockSetSessionTimeoutModalInfo.mock.calls[0][0]
+ const initialState = firstCall({
+ showModal: true, sessionEndTime: Date.now() + 60000, action: undefined, buttonDisabled: false
})
- expect(updatedState.showModal).toBe(false)
- expect(updatedState.timeLeft).toBe(0)
- expect(updatedState.buttonDisabled).toBe(false)
- expect(updatedState.action).toBeUndefined()
+ expect(initialState.showModal).toBe(false)
+ expect(initialState.action).toBe("extending")
+
+ // Check that the final call resets the action and buttonDisabled
+ const calls = mockSetSessionTimeoutModalInfo.mock.calls
+ const finalCall = calls[calls.length - 1][0]
+ const finalState = finalCall({
+ showModal: false, sessionEndTime: null, action: "extending", buttonDisabled: true
+ })
+ expect(finalState.buttonDisabled).toBe(false)
+ expect(finalState.action).toBeUndefined()
})
it("should log error when selectedRole is missing and trigger logout", async () => {
@@ -232,7 +242,7 @@ describe("useSessionTimeout", () => {
// Should set action to loggingOut
const errorCall = mockSetSessionTimeoutModalInfo.mock.calls[1][0]
const updatedState = errorCall({
- showModal: true, timeLeft: 60, action: "extending", buttonDisabled: true
+ showModal: true, sessionEndTime: Date.now() + 60000, action: "extending", buttonDisabled: true
})
expect(updatedState.action).toBe("loggingOut")
expect(updatedState.buttonDisabled).toBe(true)
@@ -293,7 +303,7 @@ describe("useSessionTimeout", () => {
const updaterFn = mockSetSessionTimeoutModalInfo.mock.calls[0][0]
const updatedState = updaterFn({
- showModal: true, timeLeft: 60, action: undefined, buttonDisabled: false
+ showModal: true, sessionEndTime: Date.now() + 60000, action: undefined, buttonDisabled: false
})
expect(updatedState.action).toBe("loggingOut")
expect(updatedState.buttonDisabled).toBe(true)
@@ -352,12 +362,8 @@ describe("useSessionTimeout", () => {
expect(logger.warn).toHaveBeenCalledWith("Session automatically timed out")
expect(handleSignoutEvent).toHaveBeenCalledWith(authMock, mockNavigate, "Timeout", "Timeout")
- // clearCountdownTimer should have reset timeLeft to 0
- const updaterFn = mockSetSessionTimeoutModalInfo.mock.calls[0][0]
- const updatedState = updaterFn({
- showModal: true, timeLeft: 60, action: undefined, buttonDisabled: false
- })
- expect(updatedState.timeLeft).toBe(0)
+ // onTimeOut only calls handleSignoutEvent, doesn't update session modal state
+ expect(mockSetSessionTimeoutModalInfo).not.toHaveBeenCalled()
})
})
})
diff --git a/packages/cpt-ui/src/App.tsx b/packages/cpt-ui/src/App.tsx
index c2ed056e29..ca3616e6e0 100644
--- a/packages/cpt-ui/src/App.tsx
+++ b/packages/cpt-ui/src/App.tsx
@@ -115,11 +115,12 @@ function AppContent() {
>
diff --git a/packages/cpt-ui/src/components/SessionTimeoutModal.tsx b/packages/cpt-ui/src/components/SessionTimeoutModal.tsx
index b6cf8d8bf7..9d26b137f1 100644
--- a/packages/cpt-ui/src/components/SessionTimeoutModal.tsx
+++ b/packages/cpt-ui/src/components/SessionTimeoutModal.tsx
@@ -1,21 +1,25 @@
-import React, {useEffect, useRef, useCallback} from "react"
-import {useLocation} from "react-router-dom"
-import {normalizePath} from "@/helpers/utils"
+import React, {
+ useEffect,
+ useRef,
+ useCallback,
+ useState
+} from "react"
import {Container} from "nhsuk-react-components"
import {EpsModal} from "@/components/EpsModal"
import {SESSION_TIMEOUT_MODAL_STRINGS} from "@/constants/ui-strings/SessionTimeoutModalStrings"
import {Button} from "./ReactRouterButton"
import {useAuth} from "@/context/AuthProvider"
-import {FRONTEND_PATHS} from "@/constants/environment"
+import {logger} from "@/helpers/logger"
interface SessionTimeoutModalProps {
isOpen: boolean
- timeLeft: number
+ sessionEndTime: number | null
onStayLoggedIn: () => Promise
onLogOut: () => Promise
onTimeOut: () => Promise
buttonDisabledState: boolean
+ isSelectYourRolePath: boolean
}
// Helper functions moved outside component to reduce cognitive complexity
@@ -87,7 +91,7 @@ const useAriaLiveAnnouncements = (
updateLiveRegion(liveRegionRef, announcement)
}
}
- }, [isOpen]) // Only run when modal opens
+ }, [isOpen, timeLeft]) // Depend on both isOpen and timeLeft
// Handle periodic announcements for screen readers
useEffect(() => {
@@ -107,19 +111,27 @@ const useAriaLiveAnnouncements = (
export const SessionTimeoutModal: React.FC = ({
isOpen,
- timeLeft,
+ sessionEndTime,
onStayLoggedIn,
onLogOut,
onTimeOut,
- buttonDisabledState
+ buttonDisabledState,
+ isSelectYourRolePath
}) => {
const liveRegionRef = useRef(null)
const auth = useAuth()
- const location = useLocation()
- const path = normalizePath(location.pathname)
- const isSelectYourRolePath = (path === FRONTEND_PATHS.SELECT_YOUR_ROLE)
+ const [, forceUpdate] = useState({})
const countdownTimerRef = useRef(null)
+ // Calculate remaining time from sessionEndTime
+ const calculateRemainingTime = useCallback((endTime: number): number => {
+ const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000))
+ return remaining
+ }, [])
+
+ // Get current timeLeft for display and announcements
+ const timeLeft = sessionEndTime ? calculateRemainingTime(sessionEndTime) : 0
+
const clearCountdownTimer = useCallback(() => {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current)
@@ -139,24 +151,27 @@ export const SessionTimeoutModal: React.FC = ({
}
}
- // // Effect to start/stop countdown based on modal visibility
+ // Effect to start/stop countdown based on modal visibility
useEffect(() => {
- if (isOpen && timeLeft > 0) {
- // Only start if not already running or if starting fresh
+ if (isOpen && sessionEndTime) {
+ // Only start if not already running
if (!countdownTimerRef.current) {
-
- // Set initial time
- auth.setSessionTimeoutModalInfo(prev => ({...prev, timeLeft: timeLeft}))
-
- // Start countdown that decrements every second
+ // Start countdown that recalculates every second
countdownTimerRef.current = setInterval(() => {
- timeLeft -= 1
+ // Force component re-render to update displayed time
+ forceUpdate({})
+
+ // Calculate current remaining time
+ const currentTimeLeft = calculateRemainingTime(sessionEndTime)
- auth.setSessionTimeoutModalInfo(prev => ({...prev, timeLeft: timeLeft}))
// Auto-logout when countdown reaches 0
- if (timeLeft <= 0) {
- clearInterval(countdownTimerRef.current!)
- countdownTimerRef.current = null
+ if (currentTimeLeft <= 0) {
+ // Don't timeout if a session extension is in progress
+ if (auth.sessionTimeoutModalInfo.action === "extending") {
+ logger.info("Session countdown reached 0 but extension in progress, not timing out")
+ return
+ }
+ clearCountdownTimer()
onTimeOut()
}
}, 1000) as unknown as number
@@ -168,7 +183,7 @@ export const SessionTimeoutModal: React.FC = ({
// Cleanup on unmount
return clearCountdownTimer
- }, [isOpen]) // Only depend on showModal, not timeLeft
+ }, [isOpen, sessionEndTime, calculateRemainingTime, auth, onTimeOut, clearCountdownTimer])
return (
{
const remainingSeconds = remainingTime !== undefined ? Math.floor(remainingTime / 1000) : undefined
if (remainingSeconds !== undefined) {
- const twoMinutes = 2 * 60 // Minutes into seconds
+ const twoMinutes = 2 * 60 // 2 minutes in seconds
if (remainingSeconds <= twoMinutes && remainingSeconds > 0) {
+ // const currentPath = normalizePath(location.pathname)
+ // if (currentPath === FRONTEND_PATHS.SELECT_YOUR_ROLE) {
+ // return
+ // }
+
// Show timeout modal when 2 minutes or less remaining
logger.info("Session timeout warning triggered - showing modal", {
remainingTime,
@@ -222,7 +227,7 @@ export const AccessProvider = ({children}: {children: ReactNode}) => {
auth.setLogoutModalType("timeout")
auth.setSessionTimeoutModalInfo({
showModal: true,
- timeLeft: remainingSeconds,
+ sessionEndTime: Date.now() + (remainingSeconds * 1000),
buttonDisabled: false,
action: undefined
})
@@ -235,7 +240,7 @@ export const AccessProvider = ({children}: {children: ReactNode}) => {
logger.debug("Session still valid - hiding modal if shown", {remainingTime})
auth.setSessionTimeoutModalInfo({
showModal: false,
- timeLeft: remainingSeconds,
+ sessionEndTime: null,
buttonDisabled: false,
action: undefined
})
diff --git a/packages/cpt-ui/src/context/AuthProvider.tsx b/packages/cpt-ui/src/context/AuthProvider.tsx
index 3c1cef4e98..fbe0ac149f 100644
--- a/packages/cpt-ui/src/context/AuthProvider.tsx
+++ b/packages/cpt-ui/src/context/AuthProvider.tsx
@@ -110,7 +110,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
const [sessionTimeoutModalInfo, setSessionTimeoutModalInfo] = useLocalStorageState(
"sessionTimeoutModalInfo",
"sessionTimeoutModalInfo",
- {showModal: false, timeLeft: 0, buttonDisabled: false, action: undefined}
+ {showModal: false, sessionEndTime: null, buttonDisabled: false, action: undefined}
)
const [logoutModalType, setLogoutModalType] = useLocalStorageState<"userInitiated" | "timeout" | undefined>(
"logoutModalType",
@@ -133,7 +133,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
setIsSigningIn(false)
setIsConcurrentSession(false)
setRemainingSessionTime(undefined)
- setSessionTimeoutModalInfo({showModal: false, timeLeft: 0, buttonDisabled: false, action: undefined})
+ setSessionTimeoutModalInfo({showModal: false, sessionEndTime: null, buttonDisabled: false, action: undefined})
setSessionId(undefined)
}
diff --git a/packages/cpt-ui/src/hooks/useSessionTimeout.ts b/packages/cpt-ui/src/hooks/useSessionTimeout.ts
index 91a8891e10..970470b36d 100644
--- a/packages/cpt-ui/src/hooks/useSessionTimeout.ts
+++ b/packages/cpt-ui/src/hooks/useSessionTimeout.ts
@@ -20,10 +20,6 @@ export const useSessionTimeout = () => {
const location = useLocation()
const path = normalizePath(location.pathname)
- const clearCountdownTimer = () => {
- auth.setSessionTimeoutModalInfo(prev => ({...prev, timeLeft: 0}))
- }
-
const handleStayLoggedIn = useCallback(async () => {
// Prevent multiple simultaneous extension attempts or cross-calls
if (actionLockRef.current !== undefined) {
@@ -41,17 +37,24 @@ export const useSessionTimeout = () => {
try {
actionLockRef.current = "extending"
logger.info("User chose to extend session")
- auth.setSessionTimeoutModalInfo(prev => ({...prev, action: "extending", buttonDisabled: true}))
+
+ auth.setLogoutModalType(undefined)
+ auth.setSessionTimeoutModalInfo(prev => ({
+ ...prev,
+ showModal: false,
+ sessionEndTime: null,
+ action: "extending",
+ buttonDisabled: true
+ }))
// Call the selectedRole API with current role to refresh session
if (auth.selectedRole) {
await updateRemoteSelectedRole(auth.selectedRole)
logger.info("Session extended successfully")
- // Hide modal and refresh user info
- auth.setLogoutModalType(undefined)
+ // Reset state after successful extension
auth.setSessionTimeoutModalInfo(
- prev => ({...prev, showModal: false, timeLeft: 0, buttonDisabled: false, action: undefined}))
+ prev => ({...prev, buttonDisabled: false, action: undefined}))
actionLockRef.current = undefined
await auth.updateTrackerUserInfo()
} else {
@@ -63,7 +66,15 @@ export const useSessionTimeout = () => {
}
} catch (error) {
logger.error("Error extending session:", error)
- auth.setSessionTimeoutModalInfo(prev => ({...prev, action: "loggingOut", buttonDisabled: true}))
+ // Hide modal and clear timer, then proceed to logout
+ auth.setLogoutModalType(undefined)
+ auth.setSessionTimeoutModalInfo(prev => ({
+ ...prev,
+ showModal: false,
+ sessionEndTime: null,
+ action: "loggingOut",
+ buttonDisabled: true
+ }))
// Clear the extending lock so handleLogOut can proceed with logout
actionLockRef.current = undefined
await handleLogOut()
@@ -85,7 +96,6 @@ export const useSessionTimeout = () => {
const handleTimeout = useCallback(async () => {
logger.warn("Session automatically timed out")
- clearCountdownTimer()
await handleSignoutEvent(auth, navigate, "Timeout", "Timeout")
}, [auth])