diff --git a/.env.example b/.env.example index 4825e8c2..f8328548 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ REACT_APP_OIDC_AUDIENCES=kukkuu-api-test REACT_APP_API_URI=https://kukkuu.api.test.hel.ninja/graphql REACT_APP_SENTRY_DSN=https://c89ee3f57dc94ffd940d1df1a353b97f@sentry.hel.ninja/55 REACT_APP_IS_TEST_ENVIRONMENT=0 +REACT_APP_IDLE_TIMEOUT_IN_MS=3600000 BROWSER_TESTS_UID= BROWSER_TESTS_PWD= diff --git a/.env.local.example b/.env.local.example index 09f388b7..debc7b76 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,3 +8,4 @@ REACT_APP_OIDC_SCOPE="openid profile email" REACT_APP_OIDC_AUDIENCES=kukkuu-api-dev REACT_APP_API_URI=http://localhost:8081/graphql REACT_APP_SENTRY_DSN= +REACT_APP_IDLE_TIMEOUT_IN_MS=3600000 diff --git a/.env.test b/.env.test index bc551f03..cdbafeb9 100644 --- a/.env.test +++ b/.env.test @@ -10,6 +10,7 @@ REACT_APP_OIDC_SERVER_TYPE=KEYCLOAK REACT_APP_API_URI=http://localhost:8081/graphql REACT_APP_SENTRY_DSN=https://c89ee3f57dc94ffd940d1df1a353b97f@sentry.hel.ninja/55 REACT_APP_IS_TEST_ENVIRONMENT=0 +REACT_APP_IDLE_TIMEOUT_IN_MS=3600000 BROWSER_TESTS_ENV_URL=http://localhost:3001 BROWSER_TESTS_JWT_SIGN_SECRET= diff --git a/Dockerfile b/Dockerfile index a024880e..f3c0b2c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,6 +69,7 @@ ARG REACT_APP_FEATURE_FLAG_EXTERNAL_TICKET_SYSTEM_SUPPORT ARG REACT_APP_BUILDTIME ARG REACT_APP_RELEASE ARG REACT_APP_COMMITHASH +ARG REACT_APP_IDLE_TIMEOUT_IN_MS # Use template and inject the environment variables into .prod/nginx.conf ENV REACT_APP_BUILDTIME=${REACT_APP_BUILDTIME:-""} diff --git a/package.json b/package.json index 1704309a..5dcba31b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "react": "^18.2.0", "react-admin": "^4.16.10", "react-dom": "^18.2.0", + "react-idle-timer": "^5.7.2", "react-scripts": "^5.0.1", "typescript": "^5.3.3", "yup": "^1.3.2" diff --git a/src/common/components/appBar/KukkuuAppBar.tsx b/src/common/components/appBar/KukkuuAppBar.tsx index 3d81c0e4..0be633a8 100644 --- a/src/common/components/appBar/KukkuuAppBar.tsx +++ b/src/common/components/appBar/KukkuuAppBar.tsx @@ -7,6 +7,7 @@ import { makeStyles } from '@mui/styles'; import Config from '../../../domain/config'; import ProfileProjectDropdown from '../../../domain/profile/ProfileProjectDropdown'; import AppTitle from '../appTitle/AppTitle'; +import IdleTimer from '../../../domain/authentication/IdleTimer'; const useStyles = makeStyles({ title: { @@ -28,17 +29,22 @@ const KukkuuAppBar = ({ className }: { className?: string }) => { const isTestEnvironment = Config.IS_TEST_ENVIRONMENT; + /* note: the idle timer is placed here as its the first available shared place for components + due to the react-admin architecture + */ return ( - - - -
- + + + + +
+ + ); }; diff --git a/src/domain/application/AppConfig.ts b/src/domain/application/AppConfig.ts index 14154aba..cf479319 100644 --- a/src/domain/application/AppConfig.ts +++ b/src/domain/application/AppConfig.ts @@ -154,9 +154,17 @@ class AppConfig { * */ static get oidcSessionPollerIntervalInMs(): number { return ( - Number(process.env.REACT_APP_OIDC_SESSION_POLLING_INTERVAL_MS) ?? 60000 + Number(process.env.REACT_APP_OIDC_SESSION_POLLING_INTERVAL_MS) || 60000 ); } + + /** + * Read env variable `REACT_APP_IDLE_TIMEOUT_IN_MS`. + * Defaults to 60 minutes. + * */ + static get userIdleTimeoutInMs(): number { + return Number(process.env.REACT_APP_IDLE_TIMEOUT_IN_MS) || 3_600_000; + } } // Accept both variable and name so that variable can be correctly replaced diff --git a/src/domain/application/__tests__/App.test.tsx b/src/domain/application/__tests__/App.test.tsx index 815a4b46..3b9ff547 100644 --- a/src/domain/application/__tests__/App.test.tsx +++ b/src/domain/application/__tests__/App.test.tsx @@ -1,5 +1,7 @@ +import { MessageChannel } from 'worker_threads'; + import React from 'react'; -import { render } from '@testing-library/react'; +import { render, cleanup } from '@testing-library/react'; import App from '../App'; @@ -7,8 +9,10 @@ import App from '../App'; let console: any; beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.MessageChannel = MessageChannel; console = global.console; - global.console = { ...console, error: () => { @@ -19,6 +23,7 @@ beforeAll(() => { afterAll(() => { global.console = console; + cleanup(); }); test('renders without crashing', () => { diff --git a/src/domain/authentication/IdleTimer.tsx b/src/domain/authentication/IdleTimer.tsx new file mode 100644 index 00000000..32585ec5 --- /dev/null +++ b/src/domain/authentication/IdleTimer.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { IdleTimerProvider } from 'react-idle-timer'; + +import authService from './authService'; +import AppConfig from '../application/AppConfig'; + +type IdleTimerProps = { children: React.ReactNode }; + +function IdleTimer({ children }: IdleTimerProps) { + const onIdle = () => { + const isAuthenticated = authService.isAuthenticated(); + if (isAuthenticated) { + authService.logout(); + } + }; + + return ( + + {children} + + ); +} + +export default IdleTimer; diff --git a/src/domain/authentication/__tests__/idleProvider.test.js b/src/domain/authentication/__tests__/idleProvider.test.js new file mode 100644 index 00000000..e31df4bd --- /dev/null +++ b/src/domain/authentication/__tests__/idleProvider.test.js @@ -0,0 +1,62 @@ +import { MessageChannel } from 'worker_threads'; + +import React from 'react'; +import { + render, + cleanup, + act, + fireEvent, + waitFor, +} from '@testing-library/react'; + +import authService from '../../authentication/authService'; +import IdleTimer from '../IdleTimer'; + +const originalEnv = process.env; + +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + global.MessageChannel = MessageChannel; + process.env = { + ...originalEnv, + REACT_APP_IDLE_TIMEOUT_IN_MS: '3600000', + }; + jest.useFakeTimers(); +}); + +afterAll(() => { + cleanup(); + process.env = originalEnv; + jest.useFakeTimers(); +}); + +test('check idle timer has logged out after 60min and 1s', async () => { + render(); + const start = Date.now(); + + act(() => { + jest.setSystemTime(start + 1000 * 60 * 60 + 1); + fireEvent.focus(document); + }); + + jest.spyOn(authService, 'isAuthenticated').mockReturnValueOnce(true); + jest.spyOn(authService, 'logout'); + await waitFor(() => { + expect(authService.logout()).resolves.toEqual(1); + }); +}); + +test('check idle timer has not logged out after 50min', async () => { + render(); + const start = Date.now(); + + act(() => { + jest.setSystemTime(start + 1000 * 60 * 50); + fireEvent.focus(document); + }); + + jest.spyOn(authService, 'isAuthenticated').mockReturnValueOnce(true); + jest.spyOn(authService, 'logout'); + expect(authService.logout()).resolves.toEqual(0); +}); diff --git a/yarn.lock b/yarn.lock index e7532387..47555a58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11411,6 +11411,11 @@ react-hook-form@^7.43.9: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.47.0.tgz#a42f07266bd297ddf1f914f08f4b5f9783262f31" integrity sha512-F/TroLjTICipmHeFlMrLtNLceO2xr1jU3CyiNla5zdwsGUGu2UOxxR4UyJgLlhMwLW/Wzp4cpJ7CPfgJIeKdSg== +react-idle-timer@^5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/react-idle-timer/-/react-idle-timer-5.7.2.tgz#f506db28a86645dd1b87987116501703e512142b" + integrity sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"