mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
The Account Security store was never reset on an account switch, and the settings page only fetches while it has no verdict yet. After a switch it kept showing the previous account's 2FA state, app passwords and API keys, and "Remove" sent that account's credential ids through the new account's session. `clearAllStores` now resets the store, so the page re-probes and loads the new account, and its fetches drop answers that arrive after the stores were cleared for another account.
164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
/**
|
|
* Manages per-account state snapshots for fast switching.
|
|
* When user switches from Account A → B, we snapshot A's store state
|
|
* into memory, clear stores, then restore B's cached state.
|
|
*/
|
|
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { useContactStore } from '@/stores/contact-store';
|
|
import { useCalendarStore } from '@/stores/calendar-store';
|
|
import { useFilterStore } from '@/stores/filter-store';
|
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { useVacationStore } from '@/stores/vacation-store';
|
|
import { useFileStore } from '@/stores/file-store';
|
|
import { bumpStoreEpoch } from '@/lib/store-epoch';
|
|
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
|
|
|
// Minimal snapshot shapes - we only capture what we need
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type StoreSnapshot = Record<string, any>;
|
|
|
|
interface AccountSnapshot {
|
|
email: StoreSnapshot;
|
|
contact: StoreSnapshot;
|
|
calendar: StoreSnapshot;
|
|
filter: StoreSnapshot;
|
|
identity: StoreSnapshot;
|
|
vacation: StoreSnapshot;
|
|
}
|
|
|
|
const cache = new Map<string, AccountSnapshot>();
|
|
|
|
/** Capture current store states for the given account */
|
|
export function snapshotAccount(accountId: string): void {
|
|
const emailState = useEmailStore.getState();
|
|
const contactState = useContactStore.getState();
|
|
const calendarState = useCalendarStore.getState();
|
|
const filterState = useFilterStore.getState();
|
|
const identityState = useIdentityStore.getState();
|
|
const vacationState = useVacationStore.getState();
|
|
|
|
// Copy the captured collections so the snapshot is decoupled from the live
|
|
// store: a later in-place mutation (e.g. an array push/splice, or stamping
|
|
// fields onto a shared email object) must not retroactively corrupt a
|
|
// snapshot taken earlier.
|
|
cache.set(accountId, {
|
|
email: {
|
|
emails: [...emailState.emails],
|
|
mailboxes: [...emailState.mailboxes],
|
|
selectedEmail: emailState.selectedEmail,
|
|
selectedMailbox: emailState.selectedMailbox,
|
|
searchQuery: emailState.searchQuery,
|
|
quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
|
|
},
|
|
contact: {
|
|
contacts: [...contactState.contacts],
|
|
addressBooks: [...contactState.addressBooks],
|
|
supportsSync: contactState.supportsSync,
|
|
},
|
|
calendar: {
|
|
calendars: [...calendarState.calendars],
|
|
events: [...calendarState.events],
|
|
selectedCalendarIds: [...calendarState.selectedCalendarIds],
|
|
viewMode: calendarState.viewMode,
|
|
supportsCalendar: calendarState.supportsCalendar,
|
|
},
|
|
filter: {
|
|
rules: [...filterState.rules],
|
|
isSupported: filterState.isSupported,
|
|
},
|
|
identity: {
|
|
identities: [...identityState.identities],
|
|
preferredPrimaryId: identityState.preferredPrimaryId,
|
|
},
|
|
vacation: {
|
|
isEnabled: vacationState.isEnabled,
|
|
isSupported: vacationState.isSupported,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restore cached store states for the given account. Returns false if no cache
|
|
* exists.
|
|
*
|
|
* The snapshot only captures a subset of each store's fields (the loaded data),
|
|
* so we reset every store to its baseline first. Without this, fields outside
|
|
* the captured subset (e.g. email selection, loading flags, tag counts) would
|
|
* carry over from whatever account was active, leaking state across accounts.
|
|
* `setState` merges, so the captured fields are then layered back on top.
|
|
*/
|
|
export function restoreAccount(accountId: string): boolean {
|
|
const snapshot = cache.get(accountId);
|
|
if (!snapshot) return false;
|
|
|
|
clearAllStores();
|
|
|
|
useEmailStore.setState(snapshot.email);
|
|
useContactStore.setState(snapshot.contact);
|
|
useCalendarStore.setState(snapshot.calendar);
|
|
useFilterStore.setState(snapshot.filter);
|
|
useIdentityStore.setState(snapshot.identity);
|
|
useVacationStore.setState(snapshot.vacation);
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Clear all stores (used before restoring a different account) */
|
|
export function clearAllStores(): void {
|
|
// In-flight fetches of the outgoing account must not land in the stores
|
|
// the next account is about to fill.
|
|
bumpStoreEpoch();
|
|
useEmailStore.setState({
|
|
emails: [],
|
|
mailboxes: [],
|
|
selectedEmail: null,
|
|
selectedMailbox: '',
|
|
isLoading: false,
|
|
error: null,
|
|
searchQuery: '',
|
|
quota: null,
|
|
isPushConnected: false,
|
|
lastPushUpdate: null,
|
|
newEmailNotification: null,
|
|
selectedEmailIds: new Set<string>(),
|
|
hasMoreEmails: false,
|
|
totalEmails: 0,
|
|
expandedThreadIds: new Set<string>(),
|
|
threadEmailsCache: new Map(),
|
|
isLoadingThread: null,
|
|
selectedKeyword: null,
|
|
tagCounts: {},
|
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
|
isAdvancedSearchOpen: false,
|
|
});
|
|
useIdentityStore.getState().clearIdentities();
|
|
useContactStore.getState().clearContacts();
|
|
useVacationStore.getState().clearState();
|
|
useCalendarStore.getState().clearState();
|
|
useFilterStore.getState().clearState();
|
|
// 2FA state, app passwords and API keys are the account's own: left in
|
|
// place, the page shows them for the next account and "Remove" sends
|
|
// their ids there.
|
|
useAccountSecurityStore.getState().clearState();
|
|
// The Files drive is account-scoped like every store above, but unlike them
|
|
// it lives in a global store that outlives the FilesApp component. If we
|
|
// don't reset it here, the previous account's client + resources linger in
|
|
// memory and resurface (stale) whenever the drive is opened after a switch -
|
|
// even when FilesApp wasn't mounted at switch time (the common case: switch
|
|
// from the mail view, then navigate to Files). Clearing centrally makes the
|
|
// reset happen on every switch path, not just the one the component observes.
|
|
useFileStore.getState().clearClient();
|
|
}
|
|
|
|
/** Evict cached state for one account */
|
|
export function evictAccount(accountId: string): void {
|
|
cache.delete(accountId);
|
|
}
|
|
|
|
/** Evict all cached states */
|
|
export function evictAll(): void {
|
|
cache.clear();
|
|
}
|