fix: persist active account across reload, remount views on switch

Aligns with upstream stalwartlabs/webui@189e270 (v1.0.7, "fixes #17"),
reviewed after they independently landed a fix for the same issue:

- authStore now persists activeAccountId (sessionStorage) and
  setSession preserves it across a session refresh instead of always
  resetting to primaryAccountId, so a hard reload keeps you on the
  group account you had selected instead of bouncing back to your own.
- switchAccount now clears cacheStore (displayNames/objectLists) when
  actually changing account, since those were resolved against the
  previous account and would otherwise show stale labels.
- The ErrorBoundary wrapping MainContent is now keyed on
  activeAccountId, forcing a full remount of every view on switch.
  This is more robust than gating individual components' fetch
  effects on activeAccountId (our earlier fix in DynamicList.tsx,
  kept as-is — harmless now, but no longer load-bearing on its own)
  since it covers every current and future view type, not just lists.

Verified against a live instance: switching to a group account updates
the JMAP accountId immediately (no tab switch needed), and a full page
reload keeps the group account active instead of resetting to admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Steven RYDELL
2026-07-30 17:48:19 +02:00
co-authored by Claude Sonnet 5
parent 8c1e826d63
commit bd00b4e922
4 changed files with 75 additions and 4 deletions
+6 -1
View File
@@ -83,6 +83,7 @@ export default function AdminPanel() {
const permissions = useAccountStore((s) => s.permissions); const permissions = useAccountStore((s) => s.permissions);
const setSession = useAuthStore((s) => s.setSession); const setSession = useAuthStore((s) => s.setSession);
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const activeSection = useUIStore((s) => s.activeSection); const activeSection = useUIStore((s) => s.activeSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen); const sidebarOpen = useUIStore((s) => s.sidebarOpen);
@@ -322,7 +323,11 @@ export default function AdminPanel() {
> >
<div className="p-6"> <div className="p-6">
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto w-full max-w-7xl">
<ErrorBoundary> {/* 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. */}
<ErrorBoundary key={activeAccountId ?? 'none'}>
{section === 'Appearance' ? ( {section === 'Appearance' ? (
<AppearancePage /> <AppearancePage />
) : ( ) : (
+49
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useCacheStore } from './cacheStore';
const initialState = { const initialState = {
accessToken: null, accessToken: null,
@@ -21,6 +22,7 @@ const initialState = {
describe('authStore', () => { describe('authStore', () => {
beforeEach(() => { beforeEach(() => {
useAuthStore.setState(initialState); useAuthStore.setState(initialState);
useCacheStore.setState({ displayNames: {}, objectLists: {} });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -124,6 +126,26 @@ describe('authStore', () => {
expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); 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', () => { describe('switchAccount', () => {
@@ -149,6 +171,33 @@ describe('authStore', () => {
useAuthStore.getState().switchAccount('nonexistent'); useAuthStore.getState().switchAccount('nonexistent');
expect(useAuthStore.getState().activeAccountId).toBe('a1'); 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', () => { describe('logout', () => {
+13 -3
View File
@@ -6,6 +6,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { useCacheStore } from '@/stores/cacheStore';
interface AccountInfo { interface AccountInfo {
name: string; name: string;
@@ -71,10 +72,15 @@ export const useAuthStore = create<AuthState>()(
}, },
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => { 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({ set({
accounts, accounts,
primaryAccountId, primaryAccountId,
activeAccountId: primaryAccountId, activeAccountId: current && accounts[current] ? current : primaryAccountId,
apiUrl, apiUrl,
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}), ...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}), ...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
@@ -82,9 +88,12 @@ export const useAuthStore = create<AuthState>()(
}, },
switchAccount: (accountId) => { switchAccount: (accountId) => {
const { accounts } = get(); const { accounts, activeAccountId } = get();
if (accounts[accountId]) { if (accounts[accountId] && accountId !== activeAccountId) {
set({ activeAccountId: accountId }); 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<AuthState>()(
tokenExpiresAt: state.tokenExpiresAt, tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint, tokenEndpoint: state.tokenEndpoint,
endSessionEndpoint: state.endSessionEndpoint, endSessionEndpoint: state.endSessionEndpoint,
activeAccountId: state.activeAccountId,
}) as AuthState, }) as AuthState,
}, },
), ),
+7
View File
@@ -24,6 +24,7 @@ interface CacheState {
getObjectList: (key: string) => ObjectListEntry[] | undefined; getObjectList: (key: string) => ObjectListEntry[] | undefined;
invalidateObjectList: (key: string) => void; invalidateObjectList: (key: string) => void;
invalidateAllObjectLists: () => void; invalidateAllObjectLists: () => void;
clearAll: () => void;
} }
export const useCacheStore = create<CacheState>()((set, get) => ({ export const useCacheStore = create<CacheState>()((set, get) => ({
@@ -75,4 +76,10 @@ export const useCacheStore = create<CacheState>()((set, get) => ({
invalidateAllObjectLists: () => { invalidateAllObjectLists: () => {
set({ objectLists: {} }); 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: {} });
},
})); }));