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])