diff --git a/package.json b/package.json
index 942a4d3645..48b1128197 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
"@teamsupercell/typings-for-css-modules-loader": "^2.4.0",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^13.3.0",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"@types/classnames": "^2.2.11",
"@types/d3": "^7.4.0",
diff --git a/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx b/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx
index c6d8c7b6bb..b0fe3cb0b2 100644
--- a/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx
+++ b/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx
@@ -1,6 +1,7 @@
import React from 'react'
import reactRouterDom from 'react-router-dom'
import { cloneDeep } from 'lodash'
+import { waitFor } from '@testing-library/react'
import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils'
import Router from 'uiSrc/Router'
import { localStorageService } from 'uiSrc/services'
@@ -8,7 +9,7 @@ import { Pages } from 'uiSrc/constants'
import { appContextSelector, setCurrentWorkspace } from 'uiSrc/slices/app/context'
import { AppWorkspace } from 'uiSrc/slices/interfaces'
import MainRouter from './MainRouter'
-import * as activityMonitor from './activityMonitor'
+import * as activityMonitor from './hooks/useActivityMonitor'
jest.mock('uiSrc/services', () => ({
...jest.requireActual('uiSrc/services'),
@@ -67,14 +68,13 @@ describe('MainRouter', () => {
expect(store.getActions()).toContainEqual(setCurrentWorkspace(AppWorkspace.Databases))
})
- it('starts activity monitor on mount and stops on unmount', () => {
- const startActivityMonitorSpy = jest.spyOn(activityMonitor, 'startActivityMonitor')
- const stopActivityMonitorSpy = jest.spyOn(activityMonitor, 'stopActivityMonitor')
+ it('starts activity monitor on mount and stops on unmount', async () => {
+ const useActivityMonitorSpy = jest.spyOn(activityMonitor, 'useActivityMonitor')
- const { unmount } = render()
+ render()
- expect(startActivityMonitorSpy).toHaveBeenCalledTimes(1)
- unmount()
- expect(stopActivityMonitorSpy).toHaveBeenCalledTimes(1)
+ await waitFor(() => {
+ expect(useActivityMonitorSpy).toHaveBeenCalledTimes(1)
+ })
})
})
diff --git a/redisinsight/ui/src/components/main-router/MainRouter.tsx b/redisinsight/ui/src/components/main-router/MainRouter.tsx
index 02b92c5bb9..3bbd107112 100644
--- a/redisinsight/ui/src/components/main-router/MainRouter.tsx
+++ b/redisinsight/ui/src/components/main-router/MainRouter.tsx
@@ -18,7 +18,7 @@ import { AppWorkspace } from 'uiSrc/slices/interfaces'
import SuspenseLoader from 'uiSrc/components/main-router/components/SuspenseLoader'
import RedisStackRoutes from './components/RedisStackRoutes'
import DEFAULT_ROUTES from './constants/defaultRoutes'
-import { startActivityMonitor, stopActivityMonitor } from './activityMonitor'
+import { useActivityMonitor } from './hooks/useActivityMonitor'
const MainRouter = () => {
const { server } = useSelector(appInfoSelector)
@@ -28,6 +28,7 @@ const MainRouter = () => {
const dispatch = useDispatch()
const history = useHistory()
const { pathname } = useLocation()
+ useActivityMonitor()
const isRedisStack = server?.buildType === BuildType.RedisStack
@@ -38,13 +39,6 @@ const MainRouter = () => {
history.push(Pages.rdi)
}
}
-
- // notify parent window of last activity
- startActivityMonitor()
-
- return () => {
- stopActivityMonitor()
- }
}, [])
useEffect(() => {
diff --git a/redisinsight/ui/src/components/main-router/activityMonitor.ts b/redisinsight/ui/src/components/main-router/activityMonitor.ts
deleted file mode 100644
index 229e7b4c62..0000000000
--- a/redisinsight/ui/src/components/main-router/activityMonitor.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import throttle from 'lodash/throttle'
-import { getConfig } from 'uiSrc/config'
-
-const riConfig = getConfig()
-
-const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout
-const windowEvents = ['click', 'keydown', 'scroll', 'touchstart']
-
-const getIsMonitorEnabled = () => riConfig.app.activityMonitorOrigin && window.opener
-
-const onActivity = throttle(() => {
- try {
- // post event to parent window
- window.opener.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin)
- } catch {
- // ignore errors
- }
-}, throttleTimeout)
-
-export const startActivityMonitor = () => {
- try {
- if (getIsMonitorEnabled()) {
- windowEvents.forEach((event) => {
- window.addEventListener(event, onActivity, { passive: true, capture: true })
- })
- }
- } catch {
- // ignore errors
- }
-}
-
-export const stopActivityMonitor = () => {
- try {
- if (getIsMonitorEnabled()) {
- windowEvents.forEach((event) => {
- window.removeEventListener(event, onActivity, { capture: true })
- })
- }
- } catch {
- // ignore errors
- }
-}
diff --git a/redisinsight/ui/src/components/main-router/activityMonitor.spec.ts b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts
similarity index 53%
rename from redisinsight/ui/src/components/main-router/activityMonitor.spec.ts
rename to redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts
index 2dd6a3d12c..c07651ec91 100644
--- a/redisinsight/ui/src/components/main-router/activityMonitor.spec.ts
+++ b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts
@@ -1,7 +1,8 @@
import set from 'lodash/set'
-import { waitFor } from '@testing-library/react'
-import { startActivityMonitor, stopActivityMonitor } from 'uiSrc/components/main-router/activityMonitor'
+import { renderHook } from '@testing-library/react-hooks'
import { getConfig } from 'uiSrc/config'
+import { mockWindowLocation } from 'uiSrc/utils/test-utils'
+import { useActivityMonitor } from './useActivityMonitor'
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
@@ -21,22 +22,38 @@ const mockWindowOpener = (postMessage = jest.fn()) => {
}
}
+jest.useFakeTimers()
+
+const browserUrl = 'http://localhost/123/browser'
+const logoutUrl = 'http://localhost/#/logout'
+
+let setHrefMock: typeof jest.fn
beforeEach(() => {
jest.resetAllMocks()
+
+ const mockDate = new Date('2024-11-22T12:00:00Z')
+ jest.setSystemTime(mockDate)
+
mockConfig()
+ setHrefMock = mockWindowLocation(browserUrl)
mockWindowOpener()
})
-describe('Activity monitor', () => {
- it('should start and stop activity monitor if window.opener and monitor origin are defined', () => {
- startActivityMonitor()
+describe('useActivityMonitor', () => {
+ it('should register event handlers on mount and unregister on unmount', () => {
+ const { unmount } = renderHook(useActivityMonitor)
+
+ // Verify mount behavior
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), addEventListenerProps)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), addEventListenerProps)
- stopActivityMonitor()
+ // Trigger unmount
+ unmount()
+
+ // Verify unmount behavior
expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), removeEventListenerProps)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), removeEventListenerProps)
@@ -44,44 +61,97 @@ describe('Activity monitor', () => {
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), removeEventListenerProps)
})
- it('should not start or stop activity monitor if window.opener is undefined', () => {
+ it('should register event handlers even if window.opener is undefined', () => {
global.window.opener = undefined
- startActivityMonitor()
- stopActivityMonitor()
+ const { unmount } = renderHook(useActivityMonitor)
- expect(addEventListenerSpy).not.toHaveBeenCalled()
- expect(removeEventListenerSpy).not.toHaveBeenCalled()
+ // Verify mount behavior
+ expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
+
+ // Trigger unmount
+ unmount()
+
+ // Verify unmount behavior
+ expect(removeEventListenerSpy).toHaveBeenCalledTimes(4)
})
- it('should not start or stop activity monitor if monitor origin is falsey', () => {
+ it('should not register handlers if activityMonitorOrigin is not defined', () => {
mockConfig('')
- startActivityMonitor()
- stopActivityMonitor()
+ const { unmount } = renderHook(useActivityMonitor)
+ // Verify mount behavior
expect(addEventListenerSpy).not.toHaveBeenCalled()
+
+ // Trigger unmount
+ unmount()
+
+ // Verify unmount behavior
expect(removeEventListenerSpy).not.toHaveBeenCalled()
})
+ it('should logout user after expected amount of inactivity', async () => {
+ renderHook(useActivityMonitor)
+ jest.advanceTimersByTime(1900 * 1000)
+ expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
+ })
+
+ it('should not logout user if hook unmounts', async () => {
+ const { unmount } = renderHook(useActivityMonitor)
+ jest.advanceTimersByTime(1700 * 1000)
+ expect(setHrefMock).not.toHaveBeenCalled()
+
+ unmount()
+
+ jest.advanceTimersByTime(1000 * 1000)
+ expect(setHrefMock).not.toHaveBeenCalled()
+ })
+
+ it('should keep user logged in if they stay active', async () => {
+ renderHook(useActivityMonitor)
+
+ const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function
+
+ // act
+ jest.advanceTimersByTime(1700 * 1000)
+ activityHandler()
+ jest.advanceTimersByTime(1700 * 1000)
+
+ // assert
+ expect(setHrefMock).not.toHaveBeenCalled()
+
+ // act
+ activityHandler()
+ jest.advanceTimersByTime(1700 * 1000)
+
+ // assert
+ expect(setHrefMock).not.toHaveBeenCalled()
+
+ // act
+ jest.advanceTimersByTime(1000 * 1000)
+
+ // assert
+ expect(setHrefMock).toHaveBeenCalledWith(logoutUrl)
+ })
+
it('should throttle events and call window.opener.postMessage', async () => {
const mockPostMessage = jest.fn()
mockWindowOpener(mockPostMessage)
- startActivityMonitor()
+ renderHook(useActivityMonitor)
- expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
-
- // simulate events
+ // act
const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function
activityHandler()
activityHandler()
activityHandler()
activityHandler()
- await waitFor(() => {
- expect(mockPostMessage).toHaveBeenCalledTimes(1)
- })
+ jest.advanceTimersByTime(20_000)
+
+ // assert
+ expect(mockPostMessage).toHaveBeenCalledTimes(1)
})
it('should ignore errors from activity handler function', async () => {
@@ -90,7 +160,7 @@ describe('Activity monitor', () => {
})
mockWindowOpener(mockPostMessage)
- startActivityMonitor()
+ renderHook(useActivityMonitor)
expect(addEventListenerSpy).toHaveBeenCalledTimes(4)
@@ -106,7 +176,7 @@ describe('Activity monitor', () => {
mockWindowOpener()
- expect(startActivityMonitor).not.toThrow()
+ expect(() => renderHook(useActivityMonitor)).not.toThrow()
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
})
})
diff --git a/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts
new file mode 100644
index 0000000000..ede71eb93d
--- /dev/null
+++ b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts
@@ -0,0 +1,78 @@
+import throttle from 'lodash/throttle'
+import { useEffect } from 'react'
+import { getConfig } from 'uiSrc/config'
+
+const riConfig = getConfig()
+
+const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout
+const windowEvents = ['click', 'keydown', 'scroll', 'touchstart']
+
+const SESSION_TIME_SECONDS = riConfig.app.sessionTtlSeconds
+const SESSION_TIME_MS = SESSION_TIME_SECONDS * 1000
+const CHECK_SESSION_INTERVAL_MS = 10000
+
+let lastActivityTime: number
+let checkInterval: ReturnType | null = null
+
+const getIsMonitorEnabled = () => !!riConfig.app.activityMonitorOrigin
+
+const onActivity = throttle(() => {
+ lastActivityTime = +new Date()
+
+ try {
+ // post event to parent window
+ window.opener?.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin)
+ } catch {
+ // ignore errors
+ }
+}, throttleTimeout)
+
+export const startActivityMonitor = () => {
+ lastActivityTime = +new Date()
+ try {
+ if (getIsMonitorEnabled()) {
+ checkInterval = setInterval(() => {
+ const now = +new Date()
+ if (now - lastActivityTime >= SESSION_TIME_MS) {
+ // expire session
+ window.location.href = `${riConfig.app.activityMonitorOrigin}/#/logout`
+ }
+ }, CHECK_SESSION_INTERVAL_MS)
+
+ windowEvents.forEach((event) => {
+ window.addEventListener(event, onActivity, { passive: true, capture: true })
+ })
+ }
+ } catch {
+ // ignore errors
+ }
+}
+
+export const stopActivityMonitor = () => {
+ try {
+ if (getIsMonitorEnabled()) {
+ if (checkInterval) {
+ clearInterval(checkInterval)
+ checkInterval = null
+ }
+
+ windowEvents.forEach((event) => {
+ window.removeEventListener(event, onActivity, { capture: true })
+ })
+ }
+ } catch {
+ // ignore errors
+ }
+}
+
+export const useActivityMonitor = () => {
+ useEffect(() => {
+ startActivityMonitor()
+
+ return () => {
+ stopActivityMonitor()
+ }
+ }, [])
+}
+
+export default useActivityMonitor
diff --git a/redisinsight/ui/src/config/default.ts b/redisinsight/ui/src/config/default.ts
index ab2c698299..9277fea321 100644
--- a/redisinsight/ui/src/config/default.ts
+++ b/redisinsight/ui/src/config/default.ts
@@ -47,6 +47,7 @@ export const defaultConfig = {
returnUrlTooltip: process.env.RI_RETURN_URL_TOOLTIP || 'Back',
activityMonitorOrigin: process.env.RI_ACTIVITY_MONITOR_ORIGIN,
activityMonitorThrottleTimeout: intEnv('RI_ACTIVITY_MONITOR_THROTTLE_TIMEOUT', 30_000),
+ sessionTtlSeconds: intEnv('RI_SESSION_TTL_SECONDS', 30 * 60),
localResourcesBaseUrl: process.env.RI_LOCAL_RESOURCES_BASE_URL,
useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false)
},
diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx
index 7201846db2..c8406fe015 100644
--- a/redisinsight/ui/src/utils/test-utils.tsx
+++ b/redisinsight/ui/src/utils/test-utils.tsx
@@ -319,15 +319,22 @@ export const getMswURL = (path: string = '') =>
apiService.defaults.baseURL?.concat(path.startsWith('/') ? path.slice(1) : path) ?? ''
export const mockWindowLocation = (initialHref = '') => {
+ const setHrefMock = jest.fn()
+ let href = initialHref
Object.defineProperty(window, 'location', {
- configurable: true,
value: {
- href: initialHref,
- assign: jest.fn(),
- replace: jest.fn(),
- reload: jest.fn(),
+ set href(url) {
+ setHrefMock(url)
+ href = url
+ },
+ get href() {
+ return href
+ },
},
+ writable: true,
})
+
+ return setHrefMock
}
// re-export everything
diff --git a/yarn.lock b/yarn.lock
index 5d60624250..4b3a165b95 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2253,6 +2253,14 @@
lodash "^4.17.15"
redent "^3.0.0"
+"@testing-library/react-hooks@^8.0.1":
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
+ integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+ react-error-boundary "^3.1.0"
+
"@testing-library/react@^13.3.0":
version "13.4.0"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.4.0.tgz#6a31e3bf5951615593ad984e96b9e5e2d9380966"
@@ -11237,6 +11245,13 @@ react-dropzone@^11.2.0:
file-selector "^0.4.0"
prop-types "^15.8.1"
+react-error-boundary@^3.1.0:
+ version "3.1.4"
+ resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
+ integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
react-fast-compare@^2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
@@ -12627,16 +12642,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -12741,14 +12747,7 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -14093,7 +14092,7 @@ word-wrap@1.2.4, word-wrap@^1.2.3, word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"
integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -14111,15 +14110,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"