Skip to content

Commit f68381d

Browse files
authored
Merge pull request #4136 from RedisInsight/fe/feature/CR-264
CR-264: Session idle timeout
2 parents 975d80b + 71ef4ec commit f68381d

File tree

9 files changed

+213
-114
lines changed

9 files changed

+213
-114
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"@teamsupercell/typings-for-css-modules-loader": "^2.4.0",
9595
"@testing-library/jest-dom": "^6.2.0",
9696
"@testing-library/react": "^13.3.0",
97+
"@testing-library/react-hooks": "^8.0.1",
9798
"@testing-library/user-event": "^14.4.3",
9899
"@types/classnames": "^2.2.11",
99100
"@types/d3": "^7.4.0",

redisinsight/ui/src/components/main-router/MainRouter.spec.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import React from 'react'
22
import reactRouterDom from 'react-router-dom'
33
import { cloneDeep } from 'lodash'
4+
import { waitFor } from '@testing-library/react'
45
import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils'
56
import Router from 'uiSrc/Router'
67
import { localStorageService } from 'uiSrc/services'
78
import { Pages } from 'uiSrc/constants'
89
import { appContextSelector, setCurrentWorkspace } from 'uiSrc/slices/app/context'
910
import { AppWorkspace } from 'uiSrc/slices/interfaces'
1011
import MainRouter from './MainRouter'
11-
import * as activityMonitor from './activityMonitor'
12+
import * as activityMonitor from './hooks/useActivityMonitor'
1213

1314
jest.mock('uiSrc/services', () => ({
1415
...jest.requireActual('uiSrc/services'),
@@ -67,14 +68,13 @@ describe('MainRouter', () => {
6768
expect(store.getActions()).toContainEqual(setCurrentWorkspace(AppWorkspace.Databases))
6869
})
6970

70-
it('starts activity monitor on mount and stops on unmount', () => {
71-
const startActivityMonitorSpy = jest.spyOn(activityMonitor, 'startActivityMonitor')
72-
const stopActivityMonitorSpy = jest.spyOn(activityMonitor, 'stopActivityMonitor')
71+
it('starts activity monitor on mount and stops on unmount', async () => {
72+
const useActivityMonitorSpy = jest.spyOn(activityMonitor, 'useActivityMonitor')
7373

74-
const { unmount } = render(<Router><MainRouter /></Router>)
74+
render(<Router><MainRouter /></Router>)
7575

76-
expect(startActivityMonitorSpy).toHaveBeenCalledTimes(1)
77-
unmount()
78-
expect(stopActivityMonitorSpy).toHaveBeenCalledTimes(1)
76+
await waitFor(() => {
77+
expect(useActivityMonitorSpy).toHaveBeenCalledTimes(1)
78+
})
7979
})
8080
})

redisinsight/ui/src/components/main-router/MainRouter.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { AppWorkspace } from 'uiSrc/slices/interfaces'
1818
import SuspenseLoader from 'uiSrc/components/main-router/components/SuspenseLoader'
1919
import RedisStackRoutes from './components/RedisStackRoutes'
2020
import DEFAULT_ROUTES from './constants/defaultRoutes'
21-
import { startActivityMonitor, stopActivityMonitor } from './activityMonitor'
21+
import { useActivityMonitor } from './hooks/useActivityMonitor'
2222

2323
const MainRouter = () => {
2424
const { server } = useSelector(appInfoSelector)
@@ -28,6 +28,7 @@ const MainRouter = () => {
2828
const dispatch = useDispatch()
2929
const history = useHistory()
3030
const { pathname } = useLocation()
31+
useActivityMonitor()
3132

3233
const isRedisStack = server?.buildType === BuildType.RedisStack
3334

@@ -38,13 +39,6 @@ const MainRouter = () => {
3839
history.push(Pages.rdi)
3940
}
4041
}
41-
42-
// notify parent window of last activity
43-
startActivityMonitor()
44-
45-
return () => {
46-
stopActivityMonitor()
47-
}
4842
}, [])
4943

5044
useEffect(() => {

redisinsight/ui/src/components/main-router/activityMonitor.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.
Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import set from 'lodash/set'
2-
import { waitFor } from '@testing-library/react'
3-
import { startActivityMonitor, stopActivityMonitor } from 'uiSrc/components/main-router/activityMonitor'
2+
import { renderHook } from '@testing-library/react-hooks'
43
import { getConfig } from 'uiSrc/config'
4+
import { mockWindowLocation } from 'uiSrc/utils/test-utils'
5+
import { useActivityMonitor } from './useActivityMonitor'
56

67
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
78
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
@@ -21,67 +22,136 @@ const mockWindowOpener = (postMessage = jest.fn()) => {
2122
}
2223
}
2324

25+
jest.useFakeTimers()
26+
27+
const browserUrl = 'http://localhost/123/browser'
28+
const logoutUrl = 'http://localhost/#/logout'
29+
30+
let setHrefMock: typeof jest.fn
2431
beforeEach(() => {
2532
jest.resetAllMocks()
33+
34+
const mockDate = new Date('2024-11-22T12:00:00Z')
35+
jest.setSystemTime(mockDate)
36+
2637
mockConfig()
38+
setHrefMock = mockWindowLocation(browserUrl)
2739
mockWindowOpener()
2840
})
2941

30-
describe('Activity monitor', () => {
31-
it('should start and stop activity monitor if window.opener and monitor origin are defined', () => {
32-
startActivityMonitor()
42+
describe('useActivityMonitor', () => {
43+
it('should register event handlers on mount and unregister on unmount', () => {
44+
const { unmount } = renderHook(useActivityMonitor)
45+
46+
// Verify mount behavior
3347
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
3448
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), addEventListenerProps)
3549
expect(addEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), addEventListenerProps)
3650
expect(addEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), addEventListenerProps)
3751
expect(addEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), addEventListenerProps)
3852

39-
stopActivityMonitor()
53+
// Trigger unmount
54+
unmount()
55+
56+
// Verify unmount behavior
4057
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
4158
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), removeEventListenerProps)
4259
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), removeEventListenerProps)
4360
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), removeEventListenerProps)
4461
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), removeEventListenerProps)
4562
})
4663

47-
it('should not start or stop activity monitor if window.opener is undefined', () => {
64+
it('should register event handlers even if window.opener is undefined', () => {
4865
global.window.opener = undefined
4966

50-
startActivityMonitor()
51-
stopActivityMonitor()
67+
const { unmount } = renderHook(useActivityMonitor)
5268

53-
expect(addEventListenerSpy).not.toHaveBeenCalled()
54-
expect(removeEventListenerSpy).not.toHaveBeenCalled()
69+
// Verify mount behavior
70+
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
71+
72+
// Trigger unmount
73+
unmount()
74+
75+
// Verify unmount behavior
76+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
5577
})
5678

57-
it('should not start or stop activity monitor if monitor origin is falsey', () => {
79+
it('should not register handlers if activityMonitorOrigin is not defined', () => {
5880
mockConfig('')
5981

60-
startActivityMonitor()
61-
stopActivityMonitor()
82+
const { unmount } = renderHook(useActivityMonitor)
6283

84+
// Verify mount behavior
6385
expect(addEventListenerSpy).not.toHaveBeenCalled()
86+
87+
// Trigger unmount
88+
unmount()
89+
90+
// Verify unmount behavior
6491
expect(removeEventListenerSpy).not.toHaveBeenCalled()
6592
})
6693

94+
it('should logout user after expected amount of inactivity', async () => {
95+
renderHook(useActivityMonitor)
96+
jest.advanceTimersByTime(1900 * 1000)
97+
expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
98+
})
99+
100+
it('should not logout user if hook unmounts', async () => {
101+
const { unmount } = renderHook(useActivityMonitor)
102+
jest.advanceTimersByTime(1700 * 1000)
103+
expect(setHrefMock).not.toHaveBeenCalled()
104+
105+
unmount()
106+
107+
jest.advanceTimersByTime(1000 * 1000)
108+
expect(setHrefMock).not.toHaveBeenCalled()
109+
})
110+
111+
it('should keep user logged in if they stay active', async () => {
112+
renderHook(useActivityMonitor)
113+
114+
const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function
115+
116+
// act
117+
jest.advanceTimersByTime(1700 * 1000)
118+
activityHandler()
119+
jest.advanceTimersByTime(1700 * 1000)
120+
121+
// assert
122+
expect(setHrefMock).not.toHaveBeenCalled()
123+
124+
// act
125+
activityHandler()
126+
jest.advanceTimersByTime(1700 * 1000)
127+
128+
// assert
129+
expect(setHrefMock).not.toHaveBeenCalled()
130+
131+
// act
132+
jest.advanceTimersByTime(1000 * 1000)
133+
134+
// assert
135+
expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
136+
})
137+
67138
it('should throttle events and call window.opener.postMessage', async () => {
68139
const mockPostMessage = jest.fn()
69140

70141
mockWindowOpener(mockPostMessage)
71-
startActivityMonitor()
142+
renderHook(useActivityMonitor)
72143

73-
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
74-
75-
// simulate events
144+
// act
76145
const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function
77146
activityHandler()
78147
activityHandler()
79148
activityHandler()
80149
activityHandler()
81150

82-
await waitFor(() => {
83-
expect(mockPostMessage).toHaveBeenCalledTimes(1)
84-
})
151+
jest.advanceTimersByTime(20_000)
152+
153+
// assert
154+
expect(mockPostMessage).toHaveBeenCalledTimes(1)
85155
})
86156

87157
it('should ignore errors from activity handler function', async () => {
@@ -90,7 +160,7 @@ describe('Activity monitor', () => {
90160
})
91161

92162
mockWindowOpener(mockPostMessage)
93-
startActivityMonitor()
163+
renderHook(useActivityMonitor)
94164

95165
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
96166

@@ -106,7 +176,7 @@ describe('Activity monitor', () => {
106176

107177
mockWindowOpener()
108178

109-
expect(startActivityMonitor).not.toThrow()
179+
expect(() => renderHook(useActivityMonitor)).not.toThrow()
110180
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
111181
})
112182
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import throttle from 'lodash/throttle'
2+
import { useEffect } from 'react'
3+
import { getConfig } from 'uiSrc/config'
4+
5+
const riConfig = getConfig()
6+
7+
const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout
8+
const windowEvents = ['click', 'keydown', 'scroll', 'touchstart']
9+
10+
const SESSION_TIME_SECONDS = riConfig.app.sessionTtlSeconds
11+
const SESSION_TIME_MS = SESSION_TIME_SECONDS * 1000
12+
const CHECK_SESSION_INTERVAL_MS = 10000
13+
14+
let lastActivityTime: number
15+
let checkInterval: ReturnType<typeof setTimeout> | null = null
16+
17+
const getIsMonitorEnabled = () => !!riConfig.app.activityMonitorOrigin
18+
19+
const onActivity = throttle(() => {
20+
lastActivityTime = +new Date()
21+
22+
try {
23+
// post event to parent window
24+
window.opener?.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin)
25+
} catch {
26+
// ignore errors
27+
}
28+
}, throttleTimeout)
29+
30+
export const startActivityMonitor = () => {
31+
lastActivityTime = +new Date()
32+
try {
33+
if (getIsMonitorEnabled()) {
34+
checkInterval = setInterval(() => {
35+
const now = +new Date()
36+
if (now - lastActivityTime >= SESSION_TIME_MS) {
37+
// expire session
38+
window.location.href = `${riConfig.app.activityMonitorOrigin}/#/logout`
39+
}
40+
}, CHECK_SESSION_INTERVAL_MS)
41+
42+
windowEvents.forEach((event) => {
43+
window.addEventListener(event, onActivity, { passive: true, capture: true })
44+
})
45+
}
46+
} catch {
47+
// ignore errors
48+
}
49+
}
50+
51+
export const stopActivityMonitor = () => {
52+
try {
53+
if (getIsMonitorEnabled()) {
54+
if (checkInterval) {
55+
clearInterval(checkInterval)
56+
checkInterval = null
57+
}
58+
59+
windowEvents.forEach((event) => {
60+
window.removeEventListener(event, onActivity, { capture: true })
61+
})
62+
}
63+
} catch {
64+
// ignore errors
65+
}
66+
}
67+
68+
export const useActivityMonitor = () => {
69+
useEffect(() => {
70+
startActivityMonitor()
71+
72+
return () => {
73+
stopActivityMonitor()
74+
}
75+
}, [])
76+
}
77+
78+
export default useActivityMonitor

redisinsight/ui/src/config/default.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const defaultConfig = {
4747
returnUrlTooltip: process.env.RI_RETURN_URL_TOOLTIP || 'Back',
4848
activityMonitorOrigin: process.env.RI_ACTIVITY_MONITOR_ORIGIN,
4949
activityMonitorThrottleTimeout: intEnv('RI_ACTIVITY_MONITOR_THROTTLE_TIMEOUT', 30_000),
50+
sessionTtlSeconds: intEnv('RI_SESSION_TTL_SECONDS', 30 * 60),
5051
localResourcesBaseUrl: process.env.RI_LOCAL_RESOURCES_BASE_URL,
5152
useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false)
5253
},

0 commit comments

Comments
 (0)