fix: enhance account switching logic and clear stores on account change
This commit is contained in:
+21
-5
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user