diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 810e46e4c..1546e80c0 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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. diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts new file mode 100644 index 000000000..85c866033 --- /dev/null +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -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); + }); +}); diff --git a/lib/plugin-storage.ts b/lib/plugin-storage.ts index 5ce4b5dd5..4d75af115 100644 --- a/lib/plugin-storage.ts +++ b/lib/plugin-storage.ts @@ -96,6 +96,22 @@ async function deleteItem(storeName: string, key: string): Promise { }); } +async function clearItems(storeName: string): Promise { + 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 { await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId); }, + /** Drop every staged file, e.g. on sign-out. */ + async clearFiles(): Promise { + await clearItems(STORE_FILE_ACCESS_PLUGIN); + }, } \ No newline at end of file diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts new file mode 100644 index 000000000..6c70a84b7 --- /dev/null +++ b/lib/sign-out-cleanup.ts @@ -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); +} diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts new file mode 100644 index 000000000..58b62b1cd --- /dev/null +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -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); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 67eebb890..6dbec0003 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -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) => 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()( @@ -2806,3 +2814,11 @@ export const useAuthStore = create()( // 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(); + }); +} diff --git a/stores/email-store.ts b/stores/email-store.ts index 7c9cc40a3..8e5711d6f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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),