added AutoLogoutProvider
Signed-off-by: Manuel Scurti <manuel.scurti@agilelab.it>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Added AutoLogoutProvider which introduces an optional automatic logout mechanism on user inactivity
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 709 KiB |
@@ -0,0 +1,96 @@
|
||||
---
|
||||
id: autologout
|
||||
title: Autologout
|
||||
# prettier-ignore
|
||||
description: This section describes how to setup the Autologout mechanism in Backstage
|
||||
---
|
||||
|
||||
This section describes how to setup the Autologout mechanism in Backstage in case your organization needs it.
|
||||
|
||||
## Summary
|
||||
|
||||
The Autologout feature is an optional added security to Backstage, designed to automatically log out users after a preconfigured duration of inactivity. This capability helps to effectively mitigate the risks associated with unauthorized access through abandoned sessions, especially in shared device scenarios.
|
||||
|
||||
The Autologout mechanism actively tracks user activity such as mouse movements, clicks, key pressing, and taps. If the system detects no activity over a set time span (idle timeout), it invalidates the user session and redirects to the login page.
|
||||
_Inactive users_ are the ones that don't perform any action on the Backstage app or that are logged in but no Backstage tab is open in the browser.
|
||||
|
||||
This feature is particularly beneficial if your application should comply with internal policies within your organization that may require automatic logout after a specific period of inactivity.
|
||||
|
||||
This is how it looks like:
|
||||
|
||||

|
||||
|
||||
## Quick start
|
||||
|
||||
To enable and configure Autologout, you will need to add the `<AutoLogoutProvider>` component to your Backstage's instance entry point, located at `App.tsx`.
|
||||
|
||||
Here's how to add it:
|
||||
|
||||
```ts
|
||||
import { AutoLogoutProvider } from '@backstage/core-components';
|
||||
|
||||
// ... App.tsx contents
|
||||
|
||||
export default app.createRoot(
|
||||
<>
|
||||
// ...
|
||||
<AutoLogoutProvider>
|
||||
<Root>{routes}</Root>
|
||||
</AutoLogoutProvider>
|
||||
// ...
|
||||
</>,
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can further adjust the Autologout settings by tweaking the available `<AutoLogoutProvider>` properties:
|
||||
|
||||
```ts
|
||||
<AutoLogoutProvider
|
||||
idleTimeoutMinutes={30}
|
||||
useWorkerTimers={false}
|
||||
logoutIfDisconnected={false}
|
||||
>
|
||||
<Root>{routes}</Root>
|
||||
</AutoLogoutProvider>
|
||||
```
|
||||
|
||||
If you prefer to have different settings for each Backstage instance deployed at your infrastructure, you can instead leverage the `<ConfigBasedAutoLogoutProvider>` which reads the Autologout settings from `app-config`.
|
||||
|
||||
To do so, adjust your `App.tsx` as follows:
|
||||
|
||||
```ts
|
||||
import { ConfigBasedAutoLogoutProvider } from '@backstage/core-components';
|
||||
|
||||
// ... App.tsx contents
|
||||
|
||||
export default app.createRoot(
|
||||
<>
|
||||
// ...
|
||||
<ConfigBasedAutoLogoutProvider>
|
||||
<Root>{routes}</Root>
|
||||
</ConfigBasedAutoLogoutProvider>
|
||||
// ...
|
||||
</>,
|
||||
);
|
||||
```
|
||||
|
||||
And add these lines into your `app-config`:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
autologout:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
You will now be able to configure the AutoLogoutProvider through your `app-config`. These are the available settings:
|
||||
|
||||
| Configuration Key | Component Property | Description | Allowed Values | Default Value |
|
||||
| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.autologout.enabled` | `enabled` | Enable/disable the Autologout feature. | `true`/`false` | `false` |
|
||||
| `auth.autologout.idleTimeoutMinutes` | `idleTimeoutMinutes` | Sets the idle time (in minutes) after which the user will be logged out. | `>= 0.5` minutes | `60` |
|
||||
| `auth.autologout.promptBeforeIdleSeconds` | `promptBeforeIdleSeconds` | Determines the time (in seconds) prior to idle state when a prompt will appear. A value of 0 disables the prompt. This must be less than the value of `idleTimeoutMinutes`. | `>= 0` seconds | `10` |
|
||||
| `auth.autologout.events` | `events` | Specifies the list of events used to detect user activity. | Allowed values are standard [DOM events](https://developer.mozilla.org/en-US/docs/Web/Events) | `'mousemove', 'keydown', 'wheel', 'DOMMouseScroll', 'mousewheel', 'mousedown', 'touchstart' 'touchmove', 'MSPointerDown', 'MSPointerMove', 'visibilitychange'` |
|
||||
| `auth.autologout.useWorkerTimers` | `useWorkerTimers` | Enables or disables the use of Node's worker thread timers instead of main thread timers. This can be beneficial if the browser is terminating timers in inactive tabs, like those used by Autologout. In case of browser incompatibility, try setting this to false. | `true`/`false` | `true` |
|
||||
| `auth.autologout.logoutIfDisconnected` | `logoutIfDisconnected` | Enable/disable autologout for disconnected users. Disconnected users are those who are logged in but do not have any active Backstage tabs open in their browsers. If enabled, such users will be automatically logged out after `idleTimeoutMinutes`. | `true`/`false` | `true` |
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export interface Config {
|
||||
auth?: {
|
||||
/**
|
||||
* Autologout feature configuration
|
||||
*/
|
||||
autologout?: {
|
||||
/**
|
||||
* Enable or disable the autologout feature
|
||||
* @visibility frontend
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Number of minutes after which the inactive user is logged out automatically.
|
||||
* Default is 60 minutes (1 hour)
|
||||
* @visibility frontend
|
||||
*/
|
||||
idleTimeoutMinutes?: number;
|
||||
|
||||
/**
|
||||
* Number of seconds before the idle timeout where the user will be asked if it's still active.
|
||||
* A dialog will be shown.
|
||||
* Default is 10 seconds.
|
||||
* Set to 0 seconds to disable the prompt.
|
||||
* @visibility frontend
|
||||
*/
|
||||
promptBeforeIdleSeconds?: number;
|
||||
|
||||
/**
|
||||
* Enable/disable the usage of worker thread timers instead of main thread timers.
|
||||
* Default is true.
|
||||
* If you experience some browser incompatibility, you may try to set this to false.
|
||||
* @visibility frontend
|
||||
*/
|
||||
useWorkerTimers?: boolean;
|
||||
|
||||
/**
|
||||
* List of events that the autologout event listener will catch to detect if the user is active
|
||||
* These events are standard DOM events.
|
||||
* @visibility frontend
|
||||
*/
|
||||
events?: string[];
|
||||
|
||||
/**
|
||||
* Enable/disable the automatic logout also on users that are logged in but with no Backstage tabs open.
|
||||
* Default is true.
|
||||
* @visibility frontend
|
||||
*/
|
||||
logoutIfDisconnected?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
"rc-progress": "3.5.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-hook-form": "^7.12.2",
|
||||
"react-idle-timer": "5.6.2",
|
||||
"react-markdown": "^8.0.0",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
@@ -101,9 +102,19 @@
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"jest-localstorage-mock": "^2.4.26",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts",
|
||||
"jest": {
|
||||
"resetMocks": false,
|
||||
"setupFiles": [
|
||||
"jest-localstorage-mock"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EventsType,
|
||||
IdleTimerProvider,
|
||||
IIdleTimer,
|
||||
workerTimers,
|
||||
} from 'react-idle-timer';
|
||||
|
||||
import {
|
||||
LAST_SEEN_ONLINE_STORAGE_KEY,
|
||||
useLogoutDisconnectedUserEffect,
|
||||
} from './disconnectedUsers';
|
||||
import { StillTherePrompt } from './StillTherePrompt';
|
||||
import { DefaultTimestampStore, TimestampStore } from './timestampStore';
|
||||
|
||||
export type AutoLogoutTrackableEvent = EventsType;
|
||||
|
||||
export type AutoLogoutProviderProps = {
|
||||
/**
|
||||
* Enable/disable the AutoLogoutMechanism.
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The amount of time (in minutes) of inactivity
|
||||
* after which the user is automatically logged out.
|
||||
* @default 60 minutes.
|
||||
*/
|
||||
idleTimeoutMinutes?: number;
|
||||
/**
|
||||
* The number of seconds before the idleTimeout expires,
|
||||
* at which the user will be alerted by a Dialog that
|
||||
* they are about to be logged out.
|
||||
* @default 10 seconds
|
||||
*/
|
||||
promptBeforeIdleSeconds?: number;
|
||||
/**
|
||||
* Enable/disable the usage of Node's worker thread timers instead of main thread timers.
|
||||
* This is helpful if you notice that the your browser is killing inactive tab's timers, like the one used by AutoLogoutProvider.
|
||||
* If you experience some browser incompatibility, you may try to set this to false.
|
||||
* @default true.
|
||||
*/
|
||||
useWorkerTimers?: boolean;
|
||||
/**
|
||||
* List of DOM events that the AutoLogoutProvider will track to determine if the user is active or not.
|
||||
* @default the default list includes all the needed events for keyboard/pointers/mouse devices.
|
||||
*/
|
||||
events?: AutoLogoutTrackableEvent[];
|
||||
/**
|
||||
* Enable/disable the autologout for disconnected users.
|
||||
* disconnected users are the ones that are logged in but have no Backstage tab open in their browsers.
|
||||
* If enabled, disconnected users will be automatically logged out after `idleTimeoutMinutes`
|
||||
* @default true
|
||||
*/
|
||||
logoutIfDisconnected?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Autologout feature enables platform engineers to add a mechanism to log out users after a configurable amount of time of inactivity.
|
||||
* When enabled, the mechanism will track user actions (mouse movement, mouse click, key pressing, taps, etc.) in order to determine if they are active or not.
|
||||
* After a certain amount of inactivity/idle time, the user session is invalidated and they are required to sign in again.
|
||||
*/
|
||||
export const AutoLogoutProvider = ({
|
||||
children,
|
||||
enabled = true,
|
||||
idleTimeoutMinutes = 60,
|
||||
promptBeforeIdleSeconds = 10,
|
||||
useWorkerTimers = true,
|
||||
events = [
|
||||
'mousemove',
|
||||
'keydown',
|
||||
'wheel',
|
||||
'DOMMouseScroll',
|
||||
'mousewheel',
|
||||
'mousedown',
|
||||
'touchstart',
|
||||
'touchmove',
|
||||
'MSPointerDown',
|
||||
'MSPointerMove',
|
||||
'visibilitychange',
|
||||
],
|
||||
logoutIfDisconnected = true,
|
||||
}: PropsWithChildren<AutoLogoutProviderProps>): JSX.Element => {
|
||||
const identityApi = useApi(identityApiRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (idleTimeoutMinutes < 0.5) {
|
||||
throw new Error(
|
||||
'❌ idleTimeoutMinutes property should be >= 0.5 minutes (30 seconds).',
|
||||
);
|
||||
}
|
||||
|
||||
if (promptBeforeIdleSeconds < 0) {
|
||||
throw new Error(
|
||||
'❌ promptBeforeIdleSeconds property should be >= 0 seconds. Set to 0 to disable the prompt.',
|
||||
);
|
||||
}
|
||||
|
||||
if (idleTimeoutMinutes * 60 <= promptBeforeIdleSeconds) {
|
||||
throw new Error(
|
||||
`❌ promptBeforeIdleSeconds should be smaller than idleTimeoutMinutes`,
|
||||
);
|
||||
}
|
||||
}, [idleTimeoutMinutes, promptBeforeIdleSeconds]);
|
||||
|
||||
const lastSeenOnlineStore: TimestampStore = useMemo(
|
||||
() => new DefaultTimestampStore(LAST_SEEN_ONLINE_STORAGE_KEY),
|
||||
[],
|
||||
);
|
||||
const [promptOpen, setPromptOpen] = useState<boolean>(false);
|
||||
|
||||
const [remainingTimeCountdown, setRemainingTimeCountdown] =
|
||||
useState<number>(0);
|
||||
|
||||
useLogoutDisconnectedUserEffect({
|
||||
enableEffect: logoutIfDisconnected,
|
||||
autologoutIsEnabled: enabled,
|
||||
idleTimeoutSeconds: idleTimeoutMinutes * 60,
|
||||
lastSeenOnlineStore,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const promptBeforeIdleMillis = promptBeforeIdleSeconds * 1000;
|
||||
const promptBeforeIdle = promptBeforeIdleMillis > 0 ? true : false;
|
||||
|
||||
const onPrompt = () => {
|
||||
// onPrompt will be called `promptBeforeIdle` milliseconds before `timeout`.
|
||||
// All events are disabled while the prompt is active.
|
||||
// If the user wishes to stay active, call the `activate()` method.
|
||||
// You can get the remaining prompt time with the `getRemainingTime()` method,
|
||||
setPromptOpen(true);
|
||||
setRemainingTimeCountdown(promptBeforeIdleMillis);
|
||||
};
|
||||
|
||||
const onIdle = () => {
|
||||
// onIdle will be called after the timeout is reached.
|
||||
// Events will be rebound as long as `stopOnMount` is not set.
|
||||
setPromptOpen(false);
|
||||
setRemainingTimeCountdown(0);
|
||||
identityApi.signOut();
|
||||
};
|
||||
|
||||
const onActive = () => {
|
||||
// onActive will only be called if `activate()` is called while `isPrompted()`
|
||||
// is true. Here you will also want to close your modal and perform
|
||||
// any active actions.
|
||||
setPromptOpen(false);
|
||||
setRemainingTimeCountdown(0);
|
||||
};
|
||||
|
||||
const onAction = (
|
||||
_event?: Event | undefined,
|
||||
_idleTimer?: IIdleTimer | null,
|
||||
) => {
|
||||
// onAction will be called if any user event is detected. The list of events that triggers a user event detection is the list of configured events
|
||||
// If any user event is detected we update the Last seen online in storage
|
||||
lastSeenOnlineStore.save(new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<IdleTimerProvider
|
||||
timeout={idleTimeoutMinutes * 60 * 1000}
|
||||
events={events}
|
||||
crossTab
|
||||
name="autologout-timer"
|
||||
timers={useWorkerTimers ? workerTimers : undefined}
|
||||
onIdle={onIdle}
|
||||
onActive={promptBeforeIdle ? onActive : undefined}
|
||||
onAction={logoutIfDisconnected ? onAction : undefined}
|
||||
onPrompt={promptBeforeIdle ? onPrompt : undefined}
|
||||
promptBeforeIdle={promptBeforeIdle ? promptBeforeIdleMillis : undefined}
|
||||
syncTimers={1000}
|
||||
>
|
||||
{promptBeforeIdle && (
|
||||
<StillTherePrompt
|
||||
open={promptOpen}
|
||||
setOpen={setPromptOpen}
|
||||
remainingTime={remainingTimeCountdown}
|
||||
setRemainingTime={setRemainingTimeCountdown}
|
||||
promptTimeoutMillis={promptBeforeIdleMillis}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</IdleTimerProvider>
|
||||
);
|
||||
};
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createMocks } from 'react-idle-timer';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { MessageChannel } from 'worker_threads';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { identityApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
|
||||
import { AutoLogoutProvider } from './AutoLogoutProvider';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
|
||||
// Mock the signOut function of identityApiRef
|
||||
const mockSignOut = jest.fn();
|
||||
const mockIdentityApi = { signOut: mockSignOut };
|
||||
|
||||
const apis = TestApiRegistry.from([identityApiRef, mockIdentityApi]);
|
||||
|
||||
describe('AutoLogoutProvider', () => {
|
||||
beforeAll(() => {
|
||||
createMocks();
|
||||
// @ts-ignore
|
||||
global.MessageChannel = MessageChannel;
|
||||
});
|
||||
|
||||
afterAll(cleanup);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children even when enabled is false', async () => {
|
||||
const { queryByText } = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<AutoLogoutProvider enabled={false}>
|
||||
<div>Test Child</div>
|
||||
</AutoLogoutProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(queryByText('Test Child')).not.toBeNull();
|
||||
expect(mockSignOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if idleTimeoutMinutes is smaller than promptBeforeSeconds', async () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ApiProvider apis={apis}>
|
||||
<AutoLogoutProvider
|
||||
idleTimeoutMinutes={0.5}
|
||||
promptBeforeIdleSeconds={120}
|
||||
>
|
||||
<div>Test Child</div>
|
||||
</AutoLogoutProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error if idleTimeoutMinutes is smaller than 30 seconds', async () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ApiProvider apis={apis}>
|
||||
<AutoLogoutProvider idleTimeoutMinutes={0.49}>
|
||||
<div>Test Child</div>
|
||||
</AutoLogoutProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { PropsWithChildren, useMemo } from 'react';
|
||||
import {
|
||||
AutoLogoutProvider,
|
||||
AutoLogoutProviderProps,
|
||||
AutoLogoutTrackableEvent,
|
||||
} from './AutoLogoutProvider';
|
||||
import React from 'react';
|
||||
|
||||
export type ConfigBasedAutoLogoutProviderProps = {};
|
||||
|
||||
/**
|
||||
* A config based flavour of the AutoLogoutProvider.
|
||||
* It allows to configure Autologout settings through `app-config`
|
||||
*/
|
||||
export const ConfigBasedAutoLogoutProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren<ConfigBasedAutoLogoutProviderProps>): JSX.Element => {
|
||||
const configApi = useApi(configApiRef);
|
||||
|
||||
const props: AutoLogoutProviderProps = useMemo(() => {
|
||||
return {
|
||||
enabled: configApi.getOptionalBoolean('auth.autologout.enabled'),
|
||||
idleTimeoutMinutes: configApi.getOptionalNumber(
|
||||
'auth.autologout.idleTimeoutMinutes',
|
||||
),
|
||||
promptBeforeIdleSeconds: configApi.getOptionalNumber(
|
||||
'auth.autologout.promptBeforeIdleSeconds',
|
||||
),
|
||||
useWorkerTimers: configApi.getOptionalBoolean(
|
||||
'auth.autologout.useWorkerTimers',
|
||||
),
|
||||
events: configApi
|
||||
.getOptionalStringArray('auth.autologout.events')
|
||||
?.map(event => event as AutoLogoutTrackableEvent),
|
||||
logoutIfDisconnected: configApi.getOptionalBoolean(
|
||||
'auth.autologout.logoutIfDisconnected',
|
||||
),
|
||||
};
|
||||
}, [configApi]);
|
||||
|
||||
return (
|
||||
<AutoLogoutProvider {...{ ...props, enabled: props.enabled ?? false }}>
|
||||
{children}
|
||||
</AutoLogoutProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useIdleTimerContext } from 'react-idle-timer';
|
||||
|
||||
export interface StillTherePromptProps {
|
||||
promptTimeoutMillis: number;
|
||||
remainingTime: number;
|
||||
setRemainingTime: (amount: number) => void;
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const StillTherePrompt = (props: StillTherePromptProps) => {
|
||||
const {
|
||||
setOpen,
|
||||
open,
|
||||
promptTimeoutMillis,
|
||||
remainingTime,
|
||||
setRemainingTime,
|
||||
} = props;
|
||||
const idleTimer = useIdleTimerContext();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setRemainingTime(Math.ceil(idleTimer.getRemainingTime()));
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [idleTimer, setRemainingTime]);
|
||||
|
||||
const handleStillHere = () => {
|
||||
setOpen(false);
|
||||
idleTimer.activate();
|
||||
};
|
||||
|
||||
const timeTillPrompt = Math.max(
|
||||
remainingTime - promptTimeoutMillis / 1000,
|
||||
0,
|
||||
);
|
||||
const seconds = timeTillPrompt > 1 ? 'seconds' : 'second';
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogTitle>Logging out due to inactivity</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
You are about to be disconnected in{' '}
|
||||
<b>
|
||||
{Math.ceil(remainingTime / 1000)} {seconds}
|
||||
</b>
|
||||
. Are you still there?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleStillHere}
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
>
|
||||
Yes! Don't log me out
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import {
|
||||
useLogoutDisconnectedUserEffect,
|
||||
UseLogoutDisconnectedUserEffectProps,
|
||||
} from './disconnectedUsers';
|
||||
|
||||
const mockIdentityApi = {
|
||||
signOut: jest.fn(),
|
||||
getProfileInfo: jest.fn(),
|
||||
getBackstageIdentity: jest.fn(),
|
||||
getCredentials: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTimestampStore = {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('useLogoutDisconnectedUserEffect', () => {
|
||||
it('should not do anything if effect is not enabled', () => {
|
||||
const props: UseLogoutDisconnectedUserEffectProps = {
|
||||
enableEffect: false,
|
||||
autologoutIsEnabled: true,
|
||||
idleTimeoutSeconds: 300,
|
||||
lastSeenOnlineStore: mockTimestampStore,
|
||||
identityApi: mockIdentityApi,
|
||||
};
|
||||
|
||||
renderHook(() => useLogoutDisconnectedUserEffect(props));
|
||||
|
||||
expect(mockTimestampStore.get).not.toHaveBeenCalled();
|
||||
expect(mockIdentityApi.signOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete the store if autologout is not enabled', () => {
|
||||
const props: UseLogoutDisconnectedUserEffectProps = {
|
||||
enableEffect: true,
|
||||
autologoutIsEnabled: false,
|
||||
idleTimeoutSeconds: 300,
|
||||
lastSeenOnlineStore: mockTimestampStore,
|
||||
identityApi: mockIdentityApi,
|
||||
};
|
||||
|
||||
renderHook(() => useLogoutDisconnectedUserEffect(props));
|
||||
|
||||
expect(mockTimestampStore.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call signOut if idle timeout passed', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const props: UseLogoutDisconnectedUserEffectProps = {
|
||||
enableEffect: true,
|
||||
autologoutIsEnabled: true,
|
||||
idleTimeoutSeconds: 1,
|
||||
lastSeenOnlineStore: {
|
||||
...mockTimestampStore,
|
||||
get: jest.fn().mockReturnValue(new Date(Date.now() - 2000)), // 2 seconds before now
|
||||
},
|
||||
identityApi: mockIdentityApi,
|
||||
};
|
||||
|
||||
renderHook(() => useLogoutDisconnectedUserEffect(props));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockIdentityApi.signOut).toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should save the current time to the store when app is loaded', () => {
|
||||
const props: UseLogoutDisconnectedUserEffectProps = {
|
||||
enableEffect: true,
|
||||
autologoutIsEnabled: true,
|
||||
idleTimeoutSeconds: 300,
|
||||
lastSeenOnlineStore: mockTimestampStore,
|
||||
identityApi: mockIdentityApi,
|
||||
};
|
||||
|
||||
renderHook(() => useLogoutDisconnectedUserEffect(props));
|
||||
|
||||
expect(mockTimestampStore.get).toHaveBeenCalled();
|
||||
expect(mockTimestampStore.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { TimestampStore } from './timestampStore';
|
||||
|
||||
export const LAST_SEEN_ONLINE_STORAGE_KEY =
|
||||
'@backstage/autologout:lastSeenOnline';
|
||||
|
||||
export type UseLogoutDisconnectedUserEffectProps = {
|
||||
enableEffect: boolean;
|
||||
autologoutIsEnabled: boolean;
|
||||
idleTimeoutSeconds: number;
|
||||
lastSeenOnlineStore: TimestampStore;
|
||||
identityApi: IdentityApi;
|
||||
};
|
||||
|
||||
export const useLogoutDisconnectedUserEffect = ({
|
||||
enableEffect,
|
||||
autologoutIsEnabled,
|
||||
idleTimeoutSeconds,
|
||||
lastSeenOnlineStore,
|
||||
identityApi,
|
||||
}: UseLogoutDisconnectedUserEffectProps) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Considers disconnected users as inactive users.
|
||||
* If all Backstage tabs are closed and idleTimeoutMinutes are passed then logout the user anyway.
|
||||
*/
|
||||
if (autologoutIsEnabled && enableEffect) {
|
||||
const lastSeenOnline = lastSeenOnlineStore.get();
|
||||
if (lastSeenOnline) {
|
||||
const now = new Date();
|
||||
const nowSeconds = Math.ceil(now.getTime() / 1000);
|
||||
const lastSeenOnlineSeconds = Math.ceil(
|
||||
lastSeenOnline.getTime() / 1000,
|
||||
);
|
||||
if (nowSeconds - lastSeenOnlineSeconds > idleTimeoutSeconds) {
|
||||
identityApi.signOut();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* save for the first time when app is loaded, so that
|
||||
* if user logs in and does nothing we still have a
|
||||
* lastSeenOnline value in store
|
||||
*/
|
||||
lastSeenOnlineStore.save(new Date());
|
||||
} else {
|
||||
lastSeenOnlineStore.delete();
|
||||
}
|
||||
}, [
|
||||
autologoutIsEnabled,
|
||||
enableEffect,
|
||||
identityApi,
|
||||
idleTimeoutSeconds,
|
||||
lastSeenOnlineStore,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { LAST_SEEN_ONLINE_STORAGE_KEY } from './disconnectedUsers';
|
||||
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './AutoLogoutProvider';
|
||||
export * from './ConfigBasedAutoLogoutProvider';
|
||||
|
||||
export const AUTOLOGOUT_LAST_SEEN_ONLINE_STORAGE_KEY =
|
||||
LAST_SEEN_ONLINE_STORAGE_KEY;
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DefaultTimestampStore, TimestampStore } from './timestampStore';
|
||||
|
||||
describe('DefaultTimestampStore', () => {
|
||||
let timestampStore: TimestampStore;
|
||||
const key = 'test-key';
|
||||
const date = new Date();
|
||||
|
||||
beforeEach(() => {
|
||||
timestampStore = new DefaultTimestampStore(key);
|
||||
// Clear the mock storage before each test
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should save the date into the localStorage', () => {
|
||||
timestampStore.save(date);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(key, date.toJSON());
|
||||
expect(localStorage.__STORE__[key]).toBe(date.toJSON());
|
||||
});
|
||||
|
||||
it('should get the date from the localStorage', () => {
|
||||
localStorage.setItem(key, date.toJSON());
|
||||
const retrievedDate = timestampStore.get();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith(key);
|
||||
expect(retrievedDate).toEqual(date);
|
||||
});
|
||||
|
||||
it('should return null if no date is set in the localStorage', () => {
|
||||
const retrievedDate = timestampStore.get();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith(key);
|
||||
expect(retrievedDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete the date from the localStorage', () => {
|
||||
localStorage.setItem(key, date.toJSON());
|
||||
timestampStore.delete();
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith(key);
|
||||
expect(localStorage.__STORE__[key]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface TimestampStore {
|
||||
save(date: Date): void;
|
||||
delete(): void;
|
||||
get(): Date | null;
|
||||
}
|
||||
|
||||
export class DefaultTimestampStore implements TimestampStore {
|
||||
constructor(private readonly key: string) {}
|
||||
|
||||
save(date: Date): void {
|
||||
localStorage.setItem(this.key, date.toJSON());
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
localStorage.removeItem(this.key);
|
||||
}
|
||||
|
||||
get(): Date | null {
|
||||
const timestamp = localStorage.getItem(this.key);
|
||||
|
||||
return timestamp !== null ? new Date(Date.parse(timestamp)) : null;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export * from './AlertDisplay';
|
||||
export * from './AutoLogoutProvider';
|
||||
export * from './Avatar';
|
||||
export * from './LinkButton';
|
||||
export * from './CodeSnippet';
|
||||
|
||||
@@ -31,6 +31,7 @@ import { commonProvider } from './commonProvider';
|
||||
import { guestProvider } from './guestProvider';
|
||||
import { customProvider } from './customProvider';
|
||||
import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy';
|
||||
import { AUTOLOGOUT_LAST_SEEN_ONLINE_STORAGE_KEY } from '../../components';
|
||||
|
||||
const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider';
|
||||
|
||||
@@ -95,6 +96,7 @@ export const useSignInProviders = (
|
||||
IdentityApiSignOutProxy.from({
|
||||
identityApi,
|
||||
signOut: async () => {
|
||||
localStorage.removeItem(AUTOLOGOUT_LAST_SEEN_ONLINE_STORAGE_KEY);
|
||||
localStorage.removeItem(PROVIDER_STORAGE_KEY);
|
||||
await identityApi.signOut?.();
|
||||
},
|
||||
|
||||
@@ -4148,12 +4148,14 @@ __metadata:
|
||||
"@types/zen-observable": ^0.8.0
|
||||
ansi-regex: ^6.0.1
|
||||
classnames: ^2.2.6
|
||||
cross-fetch: ^3.1.5
|
||||
d3-selection: ^3.0.0
|
||||
d3-shape: ^3.0.0
|
||||
d3-zoom: ^3.0.0
|
||||
dagre: ^0.8.5
|
||||
history: ^5.0.0
|
||||
immer: ^9.0.1
|
||||
jest-localstorage-mock: ^2.4.26
|
||||
linkify-react: 4.1.1
|
||||
linkifyjs: 4.1.1
|
||||
lodash: ^4.17.21
|
||||
@@ -4164,6 +4166,7 @@ __metadata:
|
||||
rc-progress: 3.5.1
|
||||
react-helmet: 6.1.0
|
||||
react-hook-form: ^7.12.2
|
||||
react-idle-timer: 5.6.2
|
||||
react-markdown: ^8.0.0
|
||||
react-sparklines: ^1.7.0
|
||||
react-syntax-highlighter: ^15.4.5
|
||||
@@ -30115,6 +30118,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-localstorage-mock@npm:^2.4.26":
|
||||
version: 2.4.26
|
||||
resolution: "jest-localstorage-mock@npm:2.4.26"
|
||||
checksum: d6871dd030f04bb59c9ef71ce22e98e15e2bc0d77755f5ed466839b891a7a7aae64b6cfc8f942cbef515981c4ff2ae26ad2ccc2f9d1b426c198d0b812ab1831f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-matcher-utils@npm:^28.1.3":
|
||||
version: 28.1.3
|
||||
resolution: "jest-matcher-utils@npm:28.1.3"
|
||||
@@ -37367,6 +37377,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-idle-timer@npm:5.6.2":
|
||||
version: 5.6.2
|
||||
resolution: "react-idle-timer@npm:5.6.2"
|
||||
peerDependencies:
|
||||
react: ">=16"
|
||||
react-dom: ">=16"
|
||||
checksum: 23c31be5490be8b22f21ba374e01b751eb845a0e37d077e6369d9a95cf45625ca43b7402f46c53dffaae9e55866920c30f2b1a316e0a9500f73ed91155dd0041
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-immutable-proptypes@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "react-immutable-proptypes@npm:2.2.0"
|
||||
|
||||
Reference in New Issue
Block a user