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"