diff --git a/.changeset/pretty-parts-admire.md b/.changeset/pretty-parts-admire.md new file mode 100644 index 0000000000..87c27295d5 --- /dev/null +++ b/.changeset/pretty-parts-admire.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fix autologout not working correctly when closing all tabs diff --git a/packages/core-components/src/components/AutoLogout/AutoLogout.tsx b/packages/core-components/src/components/AutoLogout/AutoLogout.tsx index 932ac7432b..4f53ce4bd7 100644 --- a/packages/core-components/src/components/AutoLogout/AutoLogout.tsx +++ b/packages/core-components/src/components/AutoLogout/AutoLogout.tsx @@ -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(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(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, diff --git a/packages/core-components/src/components/AutoLogout/disconnectedUsers.test.ts b/packages/core-components/src/components/AutoLogout/disconnectedUsers.test.ts index 3d097dcc7f..e6fbbc7161 100644 --- a/packages/core-components/src/components/AutoLogout/disconnectedUsers.test.ts +++ b/packages/core-components/src/components/AutoLogout/disconnectedUsers.test.ts @@ -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, diff --git a/packages/core-components/src/components/AutoLogout/disconnectedUsers.ts b/packages/core-components/src/components/AutoLogout/disconnectedUsers.ts index c09e272eaa..4d1d8bf833 100644 --- a/packages/core-components/src/components/AutoLogout/disconnectedUsers.ts +++ b/packages/core-components/src/components/AutoLogout/disconnectedUsers.ts @@ -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,