From 639aa4aa423c8bfdcbeb317b40186da0fd595678 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:42:07 +0100 Subject: [PATCH] fix: drop a signed-out account's calendar feeds, and clear recent searches when an eviction removes the last account --- .../__tests__/auth-store-logout.test.ts | 66 ++++++++++++++++++- src/stores/auth-store.ts | 28 ++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/stores/__tests__/auth-store-logout.test.ts b/src/stores/__tests__/auth-store-logout.test.ts index 4c126c70..c0fe09f6 100644 --- a/src/stores/__tests__/auth-store-logout.test.ts +++ b/src/stores/__tests__/auth-store-logout.test.ts @@ -261,7 +261,7 @@ describe('what a sign-out leaves behind', () => { feeds: useCalendarSubscriptionsStore.getState().subscriptions.map((s) => s.id), }); - it('keeps the recent searches and calendar feeds while an account is still signed in', async () => { + it("keeps the recent searches and the other accounts' calendar feeds while an account is still signed in", async () => { await signedIn(A, B); useSearchHistoryStore.setState({ recentSearches: ['invoice'] }); useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] }); @@ -269,7 +269,69 @@ describe('what a sign-out leaves behind', () => { await useAuthStore.getState().logout(); await settle(); - expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${A}`, `sub-${B}`] }); + expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${B}`] }); + }); + + it("drops a removed account's calendar feeds right away", async () => { + await signedIn(A, B, C); + useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B), feed(C)] }); + + await useAuthStore.getState().removeAccount(C); + + expect(leftovers().feeds).toEqual([`sub-${A}`, `sub-${B}`]); + }); + + it('drops the feeds of an account whose sign-in is refused on the switch to it', async () => { + await signedIn(A, B); + useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] }); + client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') }; + + await useAuthStore.getState().switchAccount(B); + + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]); + expect(leftovers().feeds).toEqual([`sub-${A}`]); + }); + + it('clears them when a switch evicts the last account', async () => { + // Signed out of the active account while the last one's server was + // away; it is refused when the user picks it. + await signedIn(A); + useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null }); + client.served = null; + useSearchHistoryStore.setState({ recentSearches: ['invoice'] }); + useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] }); + client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') }; + + await useAuthStore.getState().switchAccount(A); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(leftovers()).toEqual({ searches: [], feeds: [] }); + }); + + it('clears them when the restore on launch evicts the last account', async () => { + await signedIn(A); + useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null }); + useSearchHistoryStore.setState({ recentSearches: ['invoice'] }); + useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] }); + client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') }; + + expect(await useAuthStore.getState().restoreSession()).toBe(false); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(leftovers()).toEqual({ searches: [], feeds: [] }); + }); + + it('clears them when a session retry evicts the last account', async () => { + await signedIn(A); + useAuthStore.setState({ session: null }); + useSearchHistoryStore.setState({ recentSearches: ['invoice'] }); + useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] }); + client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') }; + + expect(await useAuthStore.getState().retrySession()).toBe(false); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(leftovers()).toEqual({ searches: [], feeds: [] }); }); it('clears them when the last account signs out', async () => { diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 7312e9d0..3dfbf739 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -147,6 +147,27 @@ function clearDeviceWideAccountData(): void { useCalendarSubscriptionsStore.getState().clearAll(); } +// An account left the registry: signed out, or evicted because its +// credentials are gone or refused. Its own calendar feeds go now, since feed +// URLs are often secret; feeds made before they recorded their account stay +// until nobody is signed in. When it was the last account, the device-wide +// data goes too. +async function forgetRemovedAccount(accountId: string): Promise { + try { + const feeds = useCalendarSubscriptionsStore; + // On a cold start the stored list may not have been read yet; written + // before that, the filtered empty default would replace it. + if (!feeds.persist.hasHydrated()) await feeds.persist.rehydrate(); + const { subscriptions } = feeds.getState(); + if (subscriptions.some((s) => s.localAccountId === accountId)) { + feeds.setState({ subscriptions: subscriptions.filter((s) => s.localAccountId !== accountId) }); + } + } catch (err) { + console.warn('[auth-store] could not drop the calendar feeds of a removed account:', err); + } + if (useAccountStore.getState().accounts.length === 0) clearDeviceWideAccountData(); +} + // Drop the named account from the email cache, then reset the (per-session, // not yet per-account) contacts and calendar stores. Used by logout when // signing one account out while others remain. @@ -719,6 +740,7 @@ export const useAuthStore = create((set, get) => ({ await revokeStoredRefreshToken(currentId); await jmapClient.clearAccountCredentials(currentId); accountStore.removeAccount(currentId); + await forgetRemovedAccount(currentId); } else { await jmapClient.logout(); } @@ -877,6 +899,7 @@ export const useAuthStore = create((set, get) => ({ useEmailStore.getState().removeAccount(accountId); restorePrevious(); set({ isLoading: false, error: 'Session expired for this account' }); + await forgetRemovedAccount(accountId); return; } } catch (err) { @@ -888,6 +911,7 @@ export const useAuthStore = create((set, get) => ({ useEmailStore.getState().removeAccount(accountId); restorePrevious(); set({ isLoading: false, error: 'Session expired for this account' }); + await forgetRemovedAccount(accountId); return; } // NetworkError or anything else - keep the previous active account @@ -943,6 +967,7 @@ export const useAuthStore = create((set, get) => ({ useEmailStore.getState().removeAccount(accountId); clearViewerCaches(); accountStore.removeAccount(accountId); + await forgetRemovedAccount(accountId); }, restoreSession: async () => { @@ -1004,6 +1029,7 @@ export const useAuthStore = create((set, get) => ({ // No stored credentials (or corrupt) — genuine logout. accountStore.removeAccount(target.id); useEmailStore.getState().removeAccount(target.id); + await forgetRemovedAccount(target.id); set({ isLoading: false, hasRestoredSession: true }); return false; } @@ -1038,6 +1064,7 @@ export const useAuthStore = create((set, get) => ({ await jmapClient.clearAccountCredentials(target.id).catch(() => undefined); accountStore.removeAccount(target.id); useEmailStore.getState().removeAccount(target.id); + await forgetRemovedAccount(target.id); set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' }); return false; } @@ -1107,6 +1134,7 @@ export const useAuthStore = create((set, get) => ({ // Now we know the credentials are bad — fall back to logout flow. await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined); accountStore.removeAccount(activeAccountId); + await forgetRemovedAccount(activeAccountId); set({ isAuthenticated: false, isLoading: false,