fix: clear the accounts' leftovers when everyone signs out

A full sign-out emptied the stores but left the accounts' traces in the
browser for the next person at the computer: search history, open tabs
with message subjects, Files favourites and recent files, calendar
alert acknowledgements, and attachment bytes a plugin had staged for an
upload that never started. Other open tabs kept showing the signed-out
accounts' mail, and the boot snapshot of the mailbox list was restored
for whichever account happened to be active next.

- A full sign-out now removes those keys and the staged files, and
  tells the other tabs through a `storage` event so they leave for the
  login page.
- The boot snapshot names its account and is only restored for it.
- The login form only remembers the username for suggestions when the
  user did not decline "remember me".

Device preferences and `template-storage` stay: with settings sync off
they exist only on this browser.
This commit is contained in:
Linus Rath
2026-09-26 16:55:34 +02:00
parent 0040e5daca
commit 47342e9e08
7 changed files with 219 additions and 6 deletions
+3 -1
View File
@@ -808,7 +808,9 @@ function LoginPageContent() {
);
if (success) {
saveUsername(formData.username);
// Suggested again on this browser only when the user did not decline
// being remembered: on a shared computer the next person sees the list.
if (!rememberMeEnabled || rememberMe) saveUsername(formData.username);
if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to
// do here. Don't push to / - that would race the deep link.
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/plugin-storage', () => ({
fileStorage: { clearFiles: vi.fn(async () => {}) },
}));
import { fileStorage } from '@/lib/plugin-storage';
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
// A full sign-out left search history, open tabs (with subjects), Files
// favourites and staged attachment bytes behind for the next person at the
// computer, and other tabs kept showing the signed-out accounts' mail.
beforeEach(() => localStorage.clear());
describe('purgeSignedOutData', () => {
it("removes the accounts' leftovers and keeps device preferences", () => {
for (const key of [
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
'files-favorites', 'files-recent-files', 'files-path-stack',
'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode',
]) localStorage.setItem(key, 'x');
purgeSignedOutData();
expect(Object.keys(localStorage).sort()).toEqual([
'files-view-mode', 'locale-storage', 'settings-storage', 'template-storage', 'theme-storage',
]);
expect(fileStorage.clearFiles).toHaveBeenCalled();
});
});
describe('cross-tab sign-out', () => {
let stop: (() => void) | null = null;
afterEach(() => stop?.());
it('reaches listeners through a storage event and leaves no key behind', () => {
const seen = vi.fn();
stop = onSignedOutElsewhere(seen);
const setItem = vi.spyOn(Storage.prototype, 'setItem');
broadcastSignOut();
const [key, value] = setItem.mock.calls[0];
setItem.mockRestore();
expect(localStorage.getItem(key)).toBeNull();
// What another tab receives.
window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
window.dispatchEvent(new StorageEvent('storage', { key, newValue: null }));
window.dispatchEvent(new StorageEvent('storage', { key: 'settings-storage', newValue: '{}' }));
expect(seen).toHaveBeenCalledTimes(1);
});
});
+20
View File
@@ -96,6 +96,22 @@ async function deleteItem(storeName: string, key: string): Promise<void> {
});
}
async function clearItems(storeName: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).clear();
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
// ─── Public API ──────────────────────────────────────────────
export const pluginStorage = {
@@ -159,4 +175,8 @@ export const fileStorage = {
async deleteFile(fileId: string): Promise<void> {
await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId);
},
/** Drop every staged file, e.g. on sign-out. */
async clearFiles(): Promise<void> {
await clearItems(STORE_FILE_ACCESS_PLUGIN);
},
}
+63
View File
@@ -0,0 +1,63 @@
import { fileStorage } from '@/lib/plugin-storage';
/**
* What a full sign-out removes from this browser beyond the stores that
* clearAllStores() empties: once nobody is signed in, nothing the accounts
* produced may be left for the next person at this computer.
*
* Kept on purpose: device preferences (`settings-storage`, `theme-storage`,
* `locale-storage`, the Files view settings) and `template-storage`. With
* settings sync off they exist only here, so deleting them would destroy
* the user's own work rather than a cache.
*/
const SIGNED_OUT_STORAGE_KEYS = [
'email-snapshot',
'identity-storage',
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
// Open tabs name messages and their subjects.
'pro-tabs',
'search-history-storage',
'files-favorites',
'files-recent-files',
'files-last-parent-id',
'files-path-stack',
];
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
/** Remove the accounts' leftovers from browser storage. */
export function purgeSignedOutData(): void {
if (typeof window === 'undefined') return;
for (const key of SIGNED_OUT_STORAGE_KEYS) {
try {
window.localStorage.removeItem(key);
} catch {
/* storage unavailable */
}
}
// Attachment bytes a plugin staged for an upload that never started.
void fileStorage.clearFiles().catch(() => {});
}
/** Tell the other tabs of this browser that everyone signed out. */
export function broadcastSignOut(): void {
if (typeof window === 'undefined') return;
try {
// Other tabs see the write as a `storage` event; the key need not stay.
window.localStorage.setItem(SIGNED_OUT_SIGNAL, String(Date.now()));
window.localStorage.removeItem(SIGNED_OUT_SIGNAL);
} catch {
/* storage unavailable */
}
}
/** Run `onSignedOut` when another tab signs everyone out. */
export function onSignedOutElsewhere(onSignedOut: () => void): () => void {
const listener = (event: StorageEvent) => {
if (event.key === SIGNED_OUT_SIGNAL && event.newValue !== null) onSignedOut();
};
window.addEventListener('storage', listener);
return () => window.removeEventListener('storage', listener);
}
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The boot snapshot of the mailbox list was not tied to an account: it was
// restored for whichever account the last session had active.
const MAILBOXES = [{ id: 'inbox', name: 'Inbox', role: 'inbox' }];
function persistSession(activeAccountId: string) {
localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true, activeAccountId } }));
}
async function bootMailboxes() {
vi.resetModules();
const { useEmailStore } = await import('@/stores/email-store');
return useEmailStore.getState().mailboxes;
}
beforeEach(() => localStorage.clear());
// Each case imports the whole email store afresh, slow under a full parallel run.
describe('email boot snapshot', { timeout: 30_000 }, () => {
it('is restored for the account it was taken from', async () => {
persistSession('alice@mail.example');
localStorage.setItem('email-snapshot', JSON.stringify({ v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES }));
expect(await bootMailboxes()).toHaveLength(1);
});
it('is ignored for another account', async () => {
persistSession('bob@mail.example');
localStorage.setItem('email-snapshot', JSON.stringify({ v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES }));
expect(await bootMailboxes()).toHaveLength(0);
});
it('discards snapshots that name no account', async () => {
persistSession('alice@mail.example');
localStorage.setItem('email-snapshot', JSON.stringify({ v: 2, mailboxes: MAILBOXES }));
expect(await bootMailboxes()).toHaveLength(0);
});
});
+16
View File
@@ -19,6 +19,9 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
@@ -1170,12 +1173,17 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void {
// Calendar subscriptions outlive account switches, but their feed URLs are
// often secret: nobody is signed in any more, so none may stay behind.
useCalendarStore.getState().clearICalSubscriptions();
useSearchHistoryStore.getState().clearRecentSearches();
useCalendarNotificationStore.getState().clearAll();
clearAllPluginStorage();
// Remove persisted state AFTER the final set() so the persist middleware
// doesn't re-write stale values.
try { localStorage.removeItem('auth-storage'); } catch { /* noop */ }
try { localStorage.removeItem('account-registry'); } catch { /* noop */ }
purgeSignedOutData();
// Other tabs still hold the accounts' mail in memory.
broadcastSignOut();
}
export const useAuthStore = create<AuthState>()(
@@ -2806,3 +2814,11 @@ export const useAuthStore = create<AuthState>()(
// Expose getClientForAccount to the calendar/contact stores via a small
// shared registry - see [[stores/client-registry]] for rationale.
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
// Another tab signed everyone out: this one's cookies and account list are
// gone too, so leave for the login page instead of showing stale mail.
if (typeof window !== 'undefined') {
onSignedOutElsewhere(() => {
if (useAuthStore.getState().isAuthenticated) navigateToLogin();
});
}
+25 -5
View File
@@ -5456,20 +5456,37 @@ const EMAIL_SNAPSHOT_KEY = 'email-snapshot';
// v2: unified / cross-account lists are no longer snapshotted (their rows carry
// per-account source stamps that a fresh boot ignores, so a colliding JMAP id
// opened the wrong account's mail - #847). Older snapshots are discarded.
const EMAIL_SNAPSHOT_VERSION = 2;
// v3: the snapshot names its account and is only restored for that account.
const EMAIL_SNAPSHOT_VERSION = 3;
const EMAIL_SNAPSHOT_MAX_EMAILS = 50;
/** The signed-in session persisted by auth-store, read without importing it. */
function persistedAuthState(): { isAuthenticated?: unknown; activeAccountId?: unknown } | null {
try {
const raw = window.localStorage.getItem('auth-storage');
return raw ? JSON.parse(raw)?.state ?? null : null;
} catch {
return null;
}
}
if (typeof window !== 'undefined') {
// Restore at module load, before the first render reads the store - and only
// when the last session ended authenticated, so a logged-out visitor never
// sees cached mail.
// sees cached mail, and for the account the snapshot was taken from.
try {
const authRaw = window.localStorage.getItem('auth-storage');
const authed = authRaw ? JSON.parse(authRaw)?.state?.isAuthenticated === true : false;
const auth = persistedAuthState();
const authed = auth?.isAuthenticated === true;
const raw = authed ? window.localStorage.getItem(EMAIL_SNAPSHOT_KEY) : null;
if (raw) {
const snap = JSON.parse(raw);
if (snap?.v === EMAIL_SNAPSHOT_VERSION && Array.isArray(snap.mailboxes) && snap.mailboxes.length > 0) {
if (
snap?.v === EMAIL_SNAPSHOT_VERSION &&
typeof snap.account === 'string' &&
snap.account === auth?.activeAccountId &&
Array.isArray(snap.mailboxes) &&
snap.mailboxes.length > 0
) {
useEmailStore.setState({
mailboxes: snap.mailboxes,
selectedMailbox: typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '',
@@ -5514,9 +5531,12 @@ if (typeof window !== 'undefined') {
// accounts whose email ids collide; restored without its view flags it
// would be read as the active account's mail. (#847)
if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return;
const account = persistedAuthState()?.activeAccountId;
if (typeof account !== 'string' || !account) return;
try {
window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({
v: EMAIL_SNAPSHOT_VERSION,
account,
mailboxes: s.mailboxes,
selectedMailbox: s.selectedMailbox,
emails: s.emails.slice(0, EMAIL_SNAPSHOT_MAX_EMAILS),