diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f7aa384..5692dea 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -83,6 +83,7 @@ export default function AdminPanel() { const permissions = useAccountStore((s) => s.permissions); const setSession = useAuthStore((s) => s.setSession); const accessToken = useAuthStore((s) => s.accessToken); + const activeAccountId = useAuthStore((s) => s.activeAccountId); const setActiveSection = useUIStore((s) => s.setActiveSection); const activeSection = useUIStore((s) => s.activeSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); @@ -322,7 +323,11 @@ export default function AdminPanel() { >
- + {/* Keyed on the active account: forces MainContent (and every + view it renders) to fully remount on switch, so + account-scoped views can't keep showing stale data fetched + under the previous account. */} + {section === 'Appearance' ? ( ) : ( diff --git a/src/stores/authStore.test.ts b/src/stores/authStore.test.ts index 71be984..c91c5de 100644 --- a/src/stores/authStore.test.ts +++ b/src/stores/authStore.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useAuthStore } from './authStore'; +import { useCacheStore } from './cacheStore'; const initialState = { accessToken: null, @@ -21,6 +22,7 @@ const initialState = { describe('authStore', () => { beforeEach(() => { useAuthStore.setState(initialState); + useCacheStore.setState({ displayNames: {}, objectLists: {} }); vi.restoreAllMocks(); }); @@ -124,6 +126,26 @@ describe('authStore', () => { expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); }); + + it('preserves the previously active account across a reload if still valid', () => { + useAuthStore.setState({ activeAccountId: 'acc-2' }); + const accounts = { + 'acc-1': { name: 'Personal', isPersonal: true }, + 'acc-2': { name: 'Shared', isPersonal: false }, + }; + + useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api'); + + expect(useAuthStore.getState().activeAccountId).toBe('acc-2'); + }); + + it('falls back to primaryAccountId if the previously active account is gone', () => { + useAuthStore.setState({ activeAccountId: 'acc-removed' }); + + useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api'); + + expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); + }); }); describe('switchAccount', () => { @@ -149,6 +171,33 @@ describe('authStore', () => { useAuthStore.getState().switchAccount('nonexistent'); expect(useAuthStore.getState().activeAccountId).toBe('a1'); }); + + it('clears the cache store when switching to a different account', () => { + useAuthStore.setState({ + accounts: { + a1: { name: 'A1', isPersonal: true }, + a2: { name: 'A2', isPersonal: false }, + }, + activeAccountId: 'a1', + }); + useCacheStore.getState().setDisplayNames('x:Domain', { d1: 'example.org' }); + + useAuthStore.getState().switchAccount('a2'); + + expect(useCacheStore.getState().displayNames).toEqual({}); + }); + + it('does not clear the cache store when "switching" to the already-active account', () => { + useAuthStore.setState({ + accounts: { a1: { name: 'A1', isPersonal: true } }, + activeAccountId: 'a1', + }); + useCacheStore.getState().setDisplayNames('x:Domain', { d1: 'example.org' }); + + useAuthStore.getState().switchAccount('a1'); + + expect(useCacheStore.getState().displayNames).toEqual({ 'x:Domain': { d1: 'example.org' } }); + }); }); describe('logout', () => { diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 8b9d58d..f62f1f4 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -6,6 +6,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { useCacheStore } from '@/stores/cacheStore'; interface AccountInfo { name: string; @@ -71,10 +72,15 @@ export const useAuthStore = create()( }, setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => { + // Preserve the previously active account across a page reload (the + // session is refetched from scratch on every load) instead of + // always resetting to the primary account, as long as it's still + // in the refreshed accounts map. + const current = get().activeAccountId; set({ accounts, primaryAccountId, - activeAccountId: primaryAccountId, + activeAccountId: current && accounts[current] ? current : primaryAccountId, apiUrl, ...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}), ...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}), @@ -82,9 +88,12 @@ export const useAuthStore = create()( }, switchAccount: (accountId) => { - const { accounts } = get(); - if (accounts[accountId]) { + const { accounts, activeAccountId } = get(); + if (accounts[accountId] && accountId !== activeAccountId) { set({ activeAccountId: accountId }); + // Cached objectId display names/lists were resolved against the + // previous account and would otherwise show stale labels. + useCacheStore.getState().clearAll(); } }, @@ -134,6 +143,7 @@ export const useAuthStore = create()( tokenExpiresAt: state.tokenExpiresAt, tokenEndpoint: state.tokenEndpoint, endSessionEndpoint: state.endSessionEndpoint, + activeAccountId: state.activeAccountId, }) as AuthState, }, ), diff --git a/src/stores/cacheStore.ts b/src/stores/cacheStore.ts index d99d2f3..526ee1a 100644 --- a/src/stores/cacheStore.ts +++ b/src/stores/cacheStore.ts @@ -24,6 +24,7 @@ interface CacheState { getObjectList: (key: string) => ObjectListEntry[] | undefined; invalidateObjectList: (key: string) => void; invalidateAllObjectLists: () => void; + clearAll: () => void; } export const useCacheStore = create()((set, get) => ({ @@ -75,4 +76,10 @@ export const useCacheStore = create()((set, get) => ({ invalidateAllObjectLists: () => { set({ objectLists: {} }); }, + + // Switching the active JMAP account can make cached objectId display + // names and lists resolve against the wrong account's data. + clearAll: () => { + set({ displayNames: {}, objectLists: {} }); + }, }));