fix: drop a signed-out account's calendar feeds, and clear recent searches when an eviction removes the last account

This commit is contained in:
Linus Rath
2026-10-29 03:42:07 +01:00
parent cfd81737e3
commit 639aa4aa42
2 changed files with 92 additions and 2 deletions
+64 -2
View File
@@ -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 () => {
+28
View File
@@ -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<void> {
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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((set, get) => ({
useEmailStore.getState().removeAccount(accountId);
clearViewerCaches();
accountStore.removeAccount(accountId);
await forgetRemovedAccount(accountId);
},
restoreSession: async () => {
@@ -1004,6 +1029,7 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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,