mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user