Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
125 changes: 94 additions & 31 deletions src/app/components/ui/ukhsa/LogoutWarning/LogoutWarning.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { act, fireEvent, render, screen } from '@/config/test-utils'

import LogoutWarning from './LogoutWarning'

// Minute timeouts to use in the tests
jest.mock('@/config/constants', () => ({
logoutThresholdMinutes: 2,
logoutWarningThresholdMinutes: 1,
}))

const { logoutThresholdMinutes, logoutWarningThresholdMinutes } = jest.requireMock('@/config/constants')

// Same as above, but in micro seconds
const WARNING_COUNTDOWN = logoutWarningThresholdMinutes * 60 * 1000
const IDLE_BEFORE_WARNING = (logoutThresholdMinutes - logoutWarningThresholdMinutes) * 60 * 1000
const HALF_IDLE_BEFORE_WARNING = IDLE_BEFORE_WARNING / 2

const mockServerSignOut = jest.fn()
jest.mock('@/app/api/auth/auth.actions', () => ({
serverSignOut: () => mockServerSignOut(),
Expand All @@ -32,15 +40,15 @@ describe('LogoutWarning', () => {
test('shows modal after inactivity timeout', () => {
render(<LogoutWarning />)
act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})
expect(screen.getByText('You will be signed out')).toBeInTheDocument()
})

test('hides modal when Stay signed in is clicked', () => {
render(<LogoutWarning />)
act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})
fireEvent.click(screen.getByRole('button', { name: /stay signed in/i }))
expect(screen.queryByText('You will be signed out')).not.toBeInTheDocument()
Expand All @@ -49,76 +57,74 @@ describe('LogoutWarning', () => {

describe('countdown', () => {
test('displays correct initial countdown time', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

expect(screen.getByText(/01:00 Minutes/)).toBeInTheDocument()
})

test('countdown ticks down every second', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

act(() => {
jest.advanceTimersByTime(5000)
jest.advanceTimersByTime(5 * 1000)
})

expect(screen.getByText(/00:55 Minutes/)).toBeInTheDocument()
})

test('resets countdown after Stay signed in is clicked', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

act(() => {
jest.advanceTimersByTime(10000)
jest.advanceTimersByTime(10 * 1000)
})

fireEvent.click(screen.getByRole('button', { name: /stay signed in/i }))

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

expect(screen.getByText(/01:00 Minutes/)).toBeInTheDocument()
})

test('calls serverSignOut when countdown reaches zero', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

// Trigger the warning
act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

// Let the countdown expire
act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(WARNING_COUNTDOWN)
})

expect(mockServerSignOut).toHaveBeenCalledTimes(1)
})

test('does not call serverSignOut when Stay signed in is clicked', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

fireEvent.click(screen.getByRole('button', { name: /stay signed in/i }))

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(WARNING_COUNTDOWN)
})

expect(mockServerSignOut).not.toHaveBeenCalled()
Expand All @@ -127,42 +133,42 @@ describe('LogoutWarning', () => {

describe('inactivity reset', () => {
test('resets inactivity timer on mousemove', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(30000)
jest.advanceTimersByTime(HALF_IDLE_BEFORE_WARNING)
})

fireEvent.mouseMove(window)

act(() => {
jest.advanceTimersByTime(30000)
jest.advanceTimersByTime(HALF_IDLE_BEFORE_WARNING)
})

expect(screen.queryByText('You will be signed out')).not.toBeInTheDocument()
})

test('resets inactivity timer on keydown', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(30000)
jest.advanceTimersByTime(HALF_IDLE_BEFORE_WARNING)
})

fireEvent.keyDown(window)

act(() => {
jest.advanceTimersByTime(30000)
jest.advanceTimersByTime(HALF_IDLE_BEFORE_WARNING)
})

expect(screen.queryByText('You will be signed out')).not.toBeInTheDocument()
})

test('does not reset inactivity timer when modal is visible', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

fireEvent.mouseMove(window)
Expand All @@ -173,10 +179,10 @@ describe('LogoutWarning', () => {

describe('tab sync', () => {
test('hides modal when activity is detected in another tab', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

expect(screen.getByText('You will be signed out')).toBeInTheDocument()
Expand All @@ -194,10 +200,10 @@ describe('LogoutWarning', () => {
})

test('does not react to unrelated storage events', () => {
render(<LogoutWarning timeoutMinutes={2} warningMinutes={1} />)
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(1 * 60 * 1000)
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

act(() => {
Expand All @@ -207,4 +213,61 @@ describe('LogoutWarning', () => {
expect(screen.getByText('You will be signed out')).toBeInTheDocument()
})
})

describe('logout countdown recovery when tab is asleep', () => {
// Simulates the user switching away from the tab, then coming back:
// jest.setSystemTime moves Date.now() forward WITHOUT firing any timers,
// just like a real browser suspending JavaScript in a hidden tab.
// The visibilitychange then simulates the user returning.
const switchAwayFromTabThenReturn = (awayInMicroSeconds: number) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks to be in milliseconds not microseconds. Maybe rename it to awayInMilliseconds?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha ha, yes, of course.

Object.defineProperty(document, 'hidden', { value: true, configurable: true })
jest.setSystemTime(Date.now() + awayInMicroSeconds)
Object.defineProperty(document, 'hidden', { value: false, configurable: true })
window.dispatchEvent(new Event('visibilitychange'))
}

test('snaps countdown back to real remaining time in the warning modal when returning to the tab', () => {
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

act(() => {
jest.advanceTimersByTime(IDLE_BEFORE_WARNING)
})

expect(screen.getByText(/01:00 Minutes/)).toBeInTheDocument()

act(() => {
switchAwayFromTabThenReturn(50 * 1000)
})

// 60s countdown minus 50s away = 10s remaining
expect(screen.getByText(/00:10 Minutes/)).toBeInTheDocument()
})

test('shows warning immediately if idle threshold passed whilst the tab was hidden but logout threshold is not reached yet', () => {
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

// User switches away before the idle-before-warning period has
// elapsed, but stays away long enough for it to have passed.
act(() => {
switchAwayFromTabThenReturn(IDLE_BEFORE_WARNING + WARNING_COUNTDOWN / 2)
})

expect(screen.getByText('You will be signed out')).toBeInTheDocument()
})

test('triggers logout immediately if logout threshold has been reached whilst the tab was hidden', () => {
render(<LogoutWarning timeoutMinutes={logoutThresholdMinutes} warningMinutes={logoutWarningThresholdMinutes} />)

// Only stay on the page for an initial 5s
act(() => {
jest.advanceTimersByTime(5 * 1000)
})

act(() => {
switchAwayFromTabThenReturn(IDLE_BEFORE_WARNING + WARNING_COUNTDOWN)
})

expect(mockServerSignOut).toHaveBeenCalledTimes(1)
})
})
})
64 changes: 46 additions & 18 deletions src/app/components/ui/ukhsa/LogoutWarning/LogoutWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,72 +16,101 @@
const [secondsLeft, setSecondsLeft] = useState(warningMinutes * 60)
const [visible, setVisible] = useState(false)

// The logoutAtRef contains real clock time (ms) when sign-out will happen.
// Reading Date.now() against this keeps the countdown correct, even
// when the browser pauses timers in a background tab when going to sleep.
const logoutAtRef = useRef<number>(0)
const inactivityTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const countdownInterval = useRef<ReturnType<typeof setInterval> | null>(null)
const visibleRef = useRef(false)

// Clear all timers and intervals
const clearTimers = useCallback(() => {
if (inactivityTimer.current) clearTimeout(inactivityTimer.current)
if (countdownInterval.current) clearInterval(countdownInterval.current)
inactivityTimer.current = null
countdownInterval.current = null
}, [])

// Trigger logout by clearing timers and calling the server sign out function
const triggerLogout = useCallback(async () => {
clearTimers()
serverSignOut('/logged-out')
}, [clearTimers])

// Start the countdown when the warning is triggered
const startCountdown = useCallback(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this restart the full warning period when the tab becomes visible again? E.g, with timeoutMinutes = 4 and warningMinutes = 2, if lastActivity was 00:00 and the user returns at 03:00, this calls startCountdown(), which sets logoutAtRef to 03:00 + 2 mins = 05:00. I'd expect logout to still happen at lastActivity _timeoutMinutes, i.e 04:00 with the modal showing 01:00

@dandammann dandammann Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another good spot. Fixed.

startCountdown() gets called from different places, so I am passing an optional argument now to get round this.

if (countdownInterval.current) clearInterval(countdownInterval.current)

logoutAtRef.current = Date.now() + warningMinutes * 60 * 1000

setSecondsLeft(warningMinutes * 60)
setVisible(true)
visibleRef.current = true

countdownInterval.current = setInterval(() => {
setSecondsLeft((s) => {
if (s <= 1) {
clearInterval(countdownInterval.current!)
countdownInterval.current = null
triggerLogout()
return 0
}
return s - 1
})
const remaining = Math.max(0, Math.ceil((logoutAtRef.current - Date.now()) / 1000))
remaining === 0 ? triggerLogout() : setSecondsLeft(remaining)
}, 1000)
}, [warningMinutes, triggerLogout])

// Schedule the warning to show after the appropriate amount of inactivity
const scheduleWarning = useCallback(() => {
if (inactivityTimer.current) clearTimeout(inactivityTimer.current)
const idleBeforeWarning = (timeoutMinutes - warningMinutes) * 60 * 1000
inactivityTimer.current = setTimeout(startCountdown, idleBeforeWarning)
}, [timeoutMinutes, warningMinutes, startCountdown])

// Reset timer on user activity, but only if warning isn't already visible
const resetInactivityTimer = useCallback(() => {
if (visibleRef.current) return
localStorage.setItem('lastActivity', Date.now().toString())
scheduleWarning()
}, [scheduleWarning])

// Set up event listeners for user activity and start the initial timer
useEffect(() => {
const events = ['mousemove', 'keydown', 'mousedown', 'touchstart', 'scroll']
events.forEach((e) => window.addEventListener(e, resetInactivityTimer))
localStorage.setItem('lastActivity', Date.now().toString())
scheduleWarning()

// Fires the instant the user switches back to this tab.
// Corrects for time that passed while the browser slept and paused our timers.
const onVisibilityChange = () => {
if (document.hidden) return

if (visibleRef.current) {
// Modal is already showing, so snap the countdown to real remaining time.
// Example: User left with 1:45 min showing and was away 90 seconds, then now show 0:15.
const remaining = Math.max(0, Math.ceil((logoutAtRef.current - Date.now()) / 1000))
remaining === 0 ? triggerLogout() : setSecondsLeft(remaining)
} else {
const lastActivity = Number(localStorage.getItem('lastActivity')) || Date.now()
const idleSeconds = (Date.now() - lastActivity) / 1000

// Modal is not yet showing, so check how long the user has been idle
if (idleSeconds >= timeoutMinutes * 60) {
// Idle for longer than the full logout threshold, then log out now
triggerLogout()
} else if (idleSeconds >= (timeoutMinutes - warningMinutes) * 60) {
// Idle past the warning threshold but not the full logout threshold, then show modal.
startCountdown()
}
}
}

window.addEventListener('visibilitychange', onVisibilityChange)

Check warning on line 97 in src/app/components/ui/ukhsa/LogoutWarning/LogoutWarning.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-frontend&issues=AZ7wPlAGhivenZu06-Z3&open=AZ7wPlAGhivenZu06-Z3&pullRequest=986

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be on document instead of window? visibilitychnage fires on document?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot. Fixed.

Worked for me before, but probably wasn't browser-compatible.


return () => {
events.forEach((e) => window.removeEventListener(e, resetInactivityTimer))
window.removeEventListener('visibilitychange', onVisibilityChange)

Check warning on line 101 in src/app/components/ui/ukhsa/LogoutWarning/LogoutWarning.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-frontend&issues=AZ7wPlAGhivenZu06-Z4&open=AZ7wPlAGhivenZu06-Z4&pullRequest=986
clearTimers()
}
}, [scheduleWarning, resetInactivityTimer, clearTimers])
}, [
scheduleWarning,
resetInactivityTimer,
clearTimers,
triggerLogout,
startCountdown,
timeoutMinutes,
warningMinutes,
])

// Handle "Stay signed in" button click
const handleStaySignedIn = useCallback(() => {
clearTimers()
setVisible(false)
Expand All @@ -90,7 +119,6 @@
scheduleWarning()
}, [clearTimers, scheduleWarning])

// Listen for activity from OTHER tabs via localStorage events
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== 'lastActivity') return
Expand Down
Loading
Loading