fix: enhance account switching logic and clear stores on account change

This commit is contained in:
Linus Rath
2026-03-20 16:29:32 +01:00
parent c1c06c68bb
commit e26654a005
4 changed files with 132 additions and 25 deletions
+21 -5
View File
@@ -70,9 +70,12 @@ export default function Home() {
const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, activeAccountId } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
// Track account switches to force data reload
const lastLoadedAccountRef = useRef<string | null>(null);
// Mobile/tablet responsive hooks // Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection(); const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
@@ -289,9 +292,19 @@ export default function Home() {
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading, router]);
// Load mailboxes and emails when authenticated (only if not already loaded) // Load mailboxes and emails when authenticated
// Re-fetches on initial load (mailboxes empty) or when account switches
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { const accountChanged = lastLoadedAccountRef.current !== null && lastLoadedAccountRef.current !== activeAccountId;
if (isAuthenticated && client && (mailboxes.length === 0 || accountChanged)) {
lastLoadedAccountRef.current = activeAccountId;
// Clear stale selected email from the previous account so the viewer
// doesn't flash old content while fresh data loads.
if (accountChanged) {
selectEmail(null);
}
const loadData = async () => { const loadData = async () => {
try { try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
@@ -337,15 +350,18 @@ export default function Home() {
} }
}; };
loadData(); loadData();
} else if (isAuthenticated && client && lastLoadedAccountRef.current === null) {
// First render with existing data (e.g. restored from snapshot) — just record the account
lastLoadedAccountRef.current = activeAccountId;
} }
// Cleanup push notifications on unmount // Cleanup push notifications on unmount or client change
return () => { return () => {
if (client) { if (client) {
client.closePushNotifications(); client.closePushNotifications();
} }
}; };
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); }, [isAuthenticated, client, mailboxes.length, activeAccountId, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected, selectEmail]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => { useEffect(() => {
+14
View File
@@ -8,6 +8,7 @@ import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store'; import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store'; import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store'; import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store'; import { useVacationStore } from '@/stores/vacation-store';
@@ -97,6 +98,19 @@ export function clearAllStores(): void {
error: null, error: null,
searchQuery: '', searchQuery: '',
quota: null, 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(); useIdentityStore.getState().clearIdentities();
useContactStore.getState().clearContacts(); useContactStore.getState().clearContacts();
+92 -17
View File
@@ -34,7 +34,7 @@ interface AuthState {
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>; login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>; refreshAccessToken: () => Promise<string | null>;
logout: () => void; logout: () => Promise<void>;
logoutAll: () => void; logoutAll: () => void;
switchAccount: (accountId: string) => Promise<void>; switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
@@ -264,10 +264,12 @@ export const useAuthStore = create<AuthState>()(
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot()) ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
: accountStore.getNextCookieSlot(); : accountStore.getNextCookieSlot();
// Snapshot current account if switching away // Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId; const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) { if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId); snapshotAccount(prevAccountId);
clearAllStores();
} }
// Store client in multi-account map // Store client in multi-account map
@@ -390,10 +392,12 @@ export const useAuthStore = create<AuthState>()(
// Register in account store // Register in account store
const accountId = generateAccountId(username, serverUrl); const accountId = generateAccountId(username, serverUrl);
// Snapshot current account if switching away // Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId; const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) { if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId); snapshotAccount(prevAccountId);
clearAllStores();
} }
clients.set(accountId, client); clients.set(accountId, client);
@@ -506,7 +510,7 @@ export const useAuthStore = create<AuthState>()(
return promise; return promise;
}, },
logout: () => { logout: async () => {
const state = get(); const state = get();
const wasOAuth = state.authMode === 'oauth'; const wasOAuth = state.authMode === 'oauth';
const accountId = state.activeAccountId; const accountId = state.activeAccountId;
@@ -515,6 +519,11 @@ export const useAuthStore = create<AuthState>()(
const slot = account?.cookieSlot ?? 0; const slot = account?.cookieSlot ?? 0;
clearRefreshTimer(accountId ?? undefined); clearRefreshTimer(accountId ?? undefined);
// Null out the client BEFORE disconnecting so the page doesn't fire
// data-loading effects with the stale disconnected client while
// stores are being cleared.
set({ client: null });
state.client?.disconnect(); state.client?.disconnect();
// Remove client from multi-account map // Remove client from multi-account map
@@ -536,11 +545,56 @@ export const useAuthStore = create<AuthState>()(
clearAllStores(); clearAllStores();
// Restore next account // Restore next account
const nextClient = clients.get(nextAccount.id); let nextClient = clients.get(nextAccount.id);
// If the client isn't in memory, try to restore it from the session
if (!nextClient) {
try {
if (nextAccount.authMode === 'oauth') {
const res = await fetch(`/api/auth/token?slot=${nextAccount.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
nextClient = JMAPClient.withBearer(nextAccount.serverUrl, access_token, nextAccount.username, () => refreshFn());
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
scheduleRefresh(expires_in, get().refreshAccessToken, nextAccount.id);
}
} else if (nextAccount.authMode === 'basic' && nextAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${nextAccount.cookieSlot}`);
if (res.ok) {
const { serverUrl: sUrl, username: uName, password: pwd } = await res.json();
nextClient = new JMAPClient(sUrl, uName, pwd);
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
}
}
} catch (err) {
debug.error(`Failed to restore next account ${nextAccount.id} during logout:`, err);
nextClient = undefined;
}
}
if (nextClient) { if (nextClient) {
const restored = restoreAccount(nextAccount.id); const restored = restoreAccount(nextAccount.id);
accountStore.setActiveAccount(nextAccount.id); accountStore.setActiveAccount(nextAccount.id);
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({ set({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
@@ -552,6 +606,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false, connectionLost: false,
error: null, error: null,
activeAccountId: nextAccount.id, activeAccountId: nextAccount.id,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
}); });
if (!restored) { if (!restored) {
@@ -560,13 +616,32 @@ export const useAuthStore = create<AuthState>()(
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username); const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
set({ identities, primaryIdentity }); set({ identities, primaryIdentity });
}).catch((err) => debug.error('Failed to load identities after switch:', err)); }).catch((err) => debug.error('Failed to load identities after switch:', err));
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
} }
} else {
// Could not restore the next account — remove it and do a full logout
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
evictAccount(nextAccount.id);
accountStore.removeAccount(nextAccount.id);
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: null,
});
localStorage.removeItem('auth-storage');
clearAllStores();
redirectToLogin();
} }
} else { } else {
// No accounts remaining — full logout // No accounts remaining — full logout
@@ -784,6 +859,10 @@ export const useAuthStore = create<AuthState>()(
accountStore.setActiveAccount(accountId); accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined }); accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({ set({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
@@ -795,6 +874,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false, connectionLost: false,
error: null, error: null,
activeAccountId: accountId, activeAccountId: accountId,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
}); });
if (!restored) { if (!restored) {
@@ -806,12 +887,6 @@ export const useAuthStore = create<AuthState>()(
} catch (err) { } catch (err) {
debug.error(`Failed to load data for ${accountId}:`, err); debug.error(`Failed to load data for ${accountId}:`, err);
} }
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
} }
// Sync settings // Sync settings
+5 -3
View File
@@ -239,15 +239,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try { try {
const mailboxes = await client.getAllMailboxes(); const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected // Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox; const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) { const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts) // Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) { if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false }); set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else { } else {
set({ mailboxes, isLoading: false }); set({ mailboxes, selectedMailbox: '', isLoading: false });
} }
} else { } else {
set({ mailboxes, isLoading: false }); set({ mailboxes, isLoading: false });