Merge pull request #31466 from jescalada/31624-fix-autologout-bug

fix: auto-logout not working when closing tabs and reopening
This commit is contained in:
Patrik Oldsberg
2026-05-26 18:13:26 +02:00
committed by GitHub
4 changed files with 105 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix autologout not working correctly when closing all tabs
@@ -114,6 +114,7 @@ const ConditionalAutoLogout = ({
// Events will be rebound as long as `stopOnMount` is not set.
setPromptOpen(false);
setRemainingTimeCountdown(0);
lastSeenOnlineStore.delete();
identityApi.signOut();
};
@@ -231,18 +232,37 @@ const parseConfig = (
export const AutoLogout = (props: AutoLogoutProps): JSX.Element | null => {
const identityApi = useApi(identityApiRef);
const configApi = useApi(configApiRef);
const [isLogged, setIsLogged] = useState(false);
const [isLogged, setIsLogged] = useState<boolean | null>(null);
const lastSeenOnlineStore: TimestampStore = useMemo(
() => new DefaultTimestampStore(LAST_SEEN_ONLINE_STORAGE_KEY),
[],
);
useEffect(() => {
// if the user is not logged in, the autologout feature won't affect the app even if enabled
async function isLoggedIn(identity: IdentityApi) {
if ((await identity.getCredentials()).token) {
setIsLogged(true);
} else {
let cancelled = false;
async function checkLogin(identity: IdentityApi) {
try {
const creds = await identity.getCredentials();
if (cancelled) return;
if (creds?.token) {
setIsLogged(true);
} else {
setIsLogged(false);
lastSeenOnlineStore.delete();
}
} catch (err) {
if (cancelled) return;
setIsLogged(false);
lastSeenOnlineStore.delete();
}
}
isLoggedIn(identityApi);
}, [identityApi]);
checkLogin(identityApi);
return () => {
cancelled = true;
};
}, [lastSeenOnlineStore, identityApi]);
const {
enabled,
@@ -274,10 +294,6 @@ export const AutoLogout = (props: AutoLogoutProps): JSX.Element | null => {
}
}, [idleTimeoutMinutes, promptBeforeIdleSeconds]);
const lastSeenOnlineStore: TimestampStore = useMemo(
() => new DefaultTimestampStore(LAST_SEEN_ONLINE_STORAGE_KEY),
[],
);
const [promptOpen, setPromptOpen] = useState<boolean>(false);
const [remainingTimeCountdown, setRemainingTimeCountdown] =
@@ -285,7 +301,8 @@ export const AutoLogout = (props: AutoLogoutProps): JSX.Element | null => {
useLogoutDisconnectedUserEffect({
enableEffect: logoutIfDisconnected,
autologoutIsEnabled: enabled && isLogged,
autologoutIsEnabled: enabled,
isLoggedIn: isLogged,
idleTimeoutSeconds: idleTimeoutMinutes * 60,
lastSeenOnlineStore,
identityApi,
@@ -35,10 +35,29 @@ const mockTimestampStore = {
};
describe('useLogoutDisconnectedUserEffect', () => {
it('should not do anything if effect is not enabled', () => {
it('should not do anything if isLoggedIn has not yet resolved', () => {
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: true,
autologoutIsEnabled: true,
isLoggedIn: null,
idleTimeoutSeconds: 300,
lastSeenOnlineStore: mockTimestampStore,
identityApi: mockIdentityApi,
};
renderHook(() => useLogoutDisconnectedUserEffect(props));
expect(mockTimestampStore.get).not.toHaveBeenCalled();
expect(mockTimestampStore.delete).not.toHaveBeenCalled();
expect(mockTimestampStore.save).not.toHaveBeenCalled();
expect(mockIdentityApi.signOut).not.toHaveBeenCalled();
});
it('should not do anything if effect is not enabled and isLoggedIn is false', () => {
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: false,
autologoutIsEnabled: true,
isLoggedIn: false,
idleTimeoutSeconds: 300,
lastSeenOnlineStore: mockTimestampStore,
identityApi: mockIdentityApi,
@@ -54,6 +73,7 @@ describe('useLogoutDisconnectedUserEffect', () => {
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: true,
autologoutIsEnabled: false,
isLoggedIn: true,
idleTimeoutSeconds: 300,
lastSeenOnlineStore: mockTimestampStore,
identityApi: mockIdentityApi,
@@ -64,12 +84,34 @@ describe('useLogoutDisconnectedUserEffect', () => {
expect(mockTimestampStore.delete).toHaveBeenCalled();
});
it('should delete the store and sign out when idle timeout has passed', () => {
const staleStore = {
...mockTimestampStore,
get: jest.fn().mockReturnValue(new Date(Date.now() - 2000)),
};
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: true,
autologoutIsEnabled: true,
isLoggedIn: true,
idleTimeoutSeconds: 1,
lastSeenOnlineStore: staleStore,
identityApi: mockIdentityApi,
};
renderHook(() => useLogoutDisconnectedUserEffect(props));
expect(staleStore.delete).toHaveBeenCalled();
expect(mockIdentityApi.signOut).toHaveBeenCalled();
expect(staleStore.save).not.toHaveBeenCalled();
});
it('should call signOut if idle timeout passed', () => {
jest.useFakeTimers();
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: true,
autologoutIsEnabled: true,
isLoggedIn: true,
idleTimeoutSeconds: 1,
lastSeenOnlineStore: {
...mockTimestampStore,
@@ -93,6 +135,7 @@ describe('useLogoutDisconnectedUserEffect', () => {
const props: UseLogoutDisconnectedUserEffectProps = {
enableEffect: true,
autologoutIsEnabled: true,
isLoggedIn: true,
idleTimeoutSeconds: 300,
lastSeenOnlineStore: mockTimestampStore,
identityApi: mockIdentityApi,
@@ -24,6 +24,7 @@ export const LAST_SEEN_ONLINE_STORAGE_KEY =
export type UseLogoutDisconnectedUserEffectProps = {
enableEffect: boolean;
autologoutIsEnabled: boolean;
isLoggedIn: boolean | null;
idleTimeoutSeconds: number;
lastSeenOnlineStore: TimestampStore;
identityApi: IdentityApi;
@@ -32,6 +33,7 @@ export type UseLogoutDisconnectedUserEffectProps = {
export const useLogoutDisconnectedUserEffect = ({
enableEffect,
autologoutIsEnabled,
isLoggedIn,
idleTimeoutSeconds,
lastSeenOnlineStore,
identityApi,
@@ -41,30 +43,34 @@ export const useLogoutDisconnectedUserEffect = ({
* 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();
const shouldCheckDisconnectedUser = autologoutIsEnabled && enableEffect;
// Prevent lastSeen getting deleted before logged state is checked
if (isLoggedIn === null) {
return;
}
if (!shouldCheckDisconnectedUser || !isLoggedIn) {
lastSeenOnlineStore.delete();
return;
}
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) {
lastSeenOnlineStore.delete();
identityApi.signOut();
return;
}
}
lastSeenOnlineStore.save(new Date());
}, [
autologoutIsEnabled,
enableEffect,
isLoggedIn,
identityApi,
idleTimeoutSeconds,
lastSeenOnlineStore,