added AutoLogoutProvider
Signed-off-by: Manuel Scurti <manuel.scurti@agilelab.it>
This commit is contained in:
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?.();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user