fix: turn settings sync back on after signing out of one of several accounts

Signing out switches sync off for the signed-out account. When another
account stayed signed in, the app switched to it without turning sync
back on, so every later settings change stayed local and was replaced
by the server copy on the next load. Sign-out now resumes sync for the
account it switches to, the same way an account switch does.
This commit is contained in:
Linus Rath
2026-09-26 16:08:22 +02:00
parent efdc6a1dc3
commit 40edc41def
2 changed files with 99 additions and 7 deletions
@@ -0,0 +1,79 @@
import { beforeAll, expect, it, vi } from 'vitest';
// Signing out of one of two accounts switched to the remaining one but never
// turned settings sync back on, so later settings edits were not pushed and
// the server copy overwrote them on the next load.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer() { throw new Error('not used'); }
async connect() {}
getSessionUsername() { return this.username; }
getUsername() { return this.username; }
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
getAuthHeader() { return 'Basic eA=='; }
onConnectionChange() {}
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
supportsContacts() { return false; }
supportsPrincipals() { return false; }
supportsVacationResponse() { return false; }
supportsCalendars() { return false; }
supportsSieve() { return false; }
disconnect() {}
getAccountId() { return 'acct'; }
}
class RateLimitError extends Error {}
return { JMAPClient, RateLimitError };
});
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useSettingsStore } from '@/stores/settings-store';
const acct = (username: string, host: string, slot: number) => ({
id: `${username}@${host}`, label: username, serverUrl: `https://${host}`, username, authMode: 'basic' as const,
rememberMe: true, cookieSlot: slot, displayName: username, email: username, isConnected: false, hasError: false,
lastLoginAt: 1, avatarColor: '#000', isDefault: slot === 0,
});
const A = acct('a@x.test', 'mail.x.test', 0);
const B = acct('b@x.test', 'mail.x.test', 1);
const posts: string[] = [];
beforeAll(() => {
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const m = url.match(/\/api\/auth\/session\?slot=(\d)/);
if (m && init?.method === 'PUT') {
const a = [A, B][Number(m[1])];
return new Response(JSON.stringify({ serverUrl: a.serverUrl, username: a.username, password: 'pw' }), { status: 200 });
}
if (url.includes('/api/config')) return new Response(JSON.stringify({ settingsSyncEnabled: true }), { status: 200 });
if (url.includes('/api/settings') && init?.method === 'POST') {
posts.push(JSON.parse(String(init.body)).username);
return new Response('{}', { status: 200 });
}
if (url.includes('/api/settings')) return new Response(JSON.stringify({ settings: null }), { status: 200 });
return new Response('{}', { status: 200 });
}));
});
it('after signing out of A, settings edits made in B are synced', async () => {
useAccountStore.setState({ accounts: [A, B] as never, activeAccountId: A.id, defaultAccountId: A.id });
useAuthStore.setState({ isAuthenticated: true, activeAccountId: A.id, client: null });
const sw = (id: string) => useAuthStore.getState().switchAccount(id);
await sw(B.id); await sw(A.id); // both logins connected, A active
await sleep(50); // loadFromServer().finally(enableSync)
vi.spyOn(await import('@/lib/browser-navigation'), 'replaceWindowLocation').mockImplementation(() => {});
await useAuthStore.getState().logout(); // "Sign out" of A; the app stays signed in as B
await sleep(50);
posts.length = 0;
useSettingsStore.getState().updateSetting('fontSize', 'small' as never);
await sleep(2300);
expect(useAuthStore.getState().activeAccountId).toBe(B.id);
expect(posts).toContain('b@x.test');
}, 15000);
+20 -7
View File
@@ -715,6 +715,21 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti
*
* @param accountId The account to apply for; defaults to the active account.
*/
/**
* Load the account's synced settings and turn syncing back on for it. Every
* path that makes an account active after sync was switched off for the
* previous one must run this, or later edits are never saved to the server.
*/
function resumeSettingsSync(account: Pick<AccountEntry, 'id' | 'username' | 'serverUrl'>): void {
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(account.username, account.serverUrl).finally(() => {
useSettingsStore.getState().enableSync(account.username, account.serverUrl);
applyPreferredIdentity(account.id);
});
}).catch(() => {});
}
export function applyPreferredIdentity(accountId?: string | null): void {
const targetId = accountId ?? useAccountStore.getState().activeAccountId;
if (!targetId) return;
@@ -1940,6 +1955,10 @@ export const useAuthStore = create<AuthState>()(
}).catch((err) => debug.error('Failed to load identities after switch:', err));
}
// Sync was switched off for the signed-out account above; the
// account staying signed in needs it back.
resumeSettingsSync(nextAccount);
// The provider session is left alone: visiting its logout page
// would take the user away from the accounts still signed in
// here, which may share that session. Revoking the token ends
@@ -2272,13 +2291,7 @@ export const useAuthStore = create<AuthState>()(
void syncAccountDisplayName(accountId, targetClient, get().primaryIdentity?.name);
// Sync settings
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
applyPreferredIdentity(targetAccount.id);
});
}).catch(() => {});
resumeSettingsSync(targetAccount);
},
checkAuth: async () => {