From f0a89fdb65939c6f86390cae675832f03e2d7450 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:27:28 +0100 Subject: [PATCH] fix: forget delegate names on sign-out and when their account is removed --- lib/__tests__/sign-out-cleanup.test.ts | 2 +- .../__tests__/known-principals.test.ts | 37 +++++++++++++++++++ lib/delegation/known-principals.ts | 25 +++++++++++-- lib/sign-out-cleanup.ts | 2 + .../auth-store-remove-viewed-account.test.ts | 9 +++++ stores/auth-store.ts | 3 ++ 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 lib/delegation/__tests__/known-principals.test.ts diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 85c866033..270fc1606 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -17,7 +17,7 @@ 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', + 'files-favorites', 'files-recent-files', 'files-path-stack', 'bulwark-delegate-names', 'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode', ]) localStorage.setItem(key, 'x'); diff --git a/lib/delegation/__tests__/known-principals.test.ts b/lib/delegation/__tests__/known-principals.test.ts new file mode 100644 index 000000000..e324159ac --- /dev/null +++ b/lib/delegation/__tests__/known-principals.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { forgetPrincipals, rememberPrincipals, rememberedPrincipals } from '../known-principals'; +import type { Principal } from '@/lib/jmap/types'; + +const person = (id: string): Principal => ({ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` }); + +beforeEach(() => localStorage.clear()); + +describe('known principals', () => { + it('keeps names per account', () => { + rememberPrincipals('acc-1', [person('ck')]); + rememberPrincipals('acc-2', [person('cj')]); + expect([...rememberedPrincipals('acc-1').keys()]).toEqual(['ck']); + expect([...rememberedPrincipals('acc-2').keys()]).toEqual(['cj']); + }); + + it('forgets one account and leaves the others', () => { + rememberPrincipals('acc-1', [person('ck')]); + rememberPrincipals('acc-2', [person('cj')]); + forgetPrincipals('acc-1'); + expect(rememberedPrincipals('acc-1').size).toBe(0); + expect([...rememberedPrincipals('acc-2').keys()]).toEqual(['cj']); + }); + + it('leaves no key behind when the last account goes', () => { + rememberPrincipals('acc-1', [person('ck')]); + forgetPrincipals('acc-1'); + expect(localStorage.getItem('bulwark-delegate-names')).toBeNull(); + }); + + it('keeps at most 200 per account, dropping the oldest', () => { + rememberPrincipals('acc-1', Array.from({ length: 205 }, (_, i) => person(`p${i}`))); + const ids = [...rememberedPrincipals('acc-1').keys()]; + expect(ids).toHaveLength(200); + expect(ids[0]).toBe('p5'); + }); +}); diff --git a/lib/delegation/known-principals.ts b/lib/delegation/known-principals.ts index bc4b0e172..c6a23f786 100644 --- a/lib/delegation/known-principals.ts +++ b/lib/delegation/known-principals.ts @@ -3,14 +3,17 @@ * browser. Stalwart 1.0 with directory queries off (its default) won't name * a principal the user shares with unless that principal also shares * something back, so the "Who has access" list would otherwise show bare - * ids for the very people the user just picked by name. Per account, at - * most MAX entries, and only a convenience: losing it costs names, not - * access. + * ids for the very people the user just picked by name. Only the people + * who have access and the one just picked are kept, never the directory; + * per account, at most MAX entries, gone when the account is removed or + * everyone signs out. A convenience: losing it costs names, not access. */ import type { Principal } from '@/lib/jmap/types'; -const STORAGE_KEY = 'bulwark-delegate-names'; +/** Purged when everyone signs out (lib/sign-out-cleanup.ts). */ +export const DELEGATE_NAMES_KEY = 'bulwark-delegate-names'; +const STORAGE_KEY = DELEGATE_NAMES_KEY; const MAX = 200; type Entry = Pick; @@ -50,3 +53,17 @@ export function rememberedPrincipals(accountKey: string): Map const own = readStore()[accountKey] ?? {}; return new Map(Object.values(own).map((entry) => [entry.id, entry as Principal])); } + +/** Forget the names kept for one account (it was removed from this browser). */ +export function forgetPrincipals(accountKey: string): void { + if (!accountKey) return; + try { + const store = readStore(); + if (!(accountKey in store)) return; + delete store[accountKey]; + if (Object.keys(store).length === 0) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // storage unavailable + } +} diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index fb1b40404..d355d48db 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -24,6 +24,8 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + // Names of the people the user gave access to (Settings > Sharing). + 'bulwark-delegate-names', ]; const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; diff --git a/stores/__tests__/auth-store-remove-viewed-account.test.ts b/stores/__tests__/auth-store-remove-viewed-account.test.ts index 96008227d..a77f7f773 100644 --- a/stores/__tests__/auth-store-remove-viewed-account.test.ts +++ b/stores/__tests__/auth-store-remove-viewed-account.test.ts @@ -59,6 +59,15 @@ describe('removing a login that is not the active one', () => { useEmailStore.setState(initialEmail); }); + it("forgets the names of the people it gave access to, and keeps the other login's", () => { + localStorage.setItem('bulwark-delegate-names', JSON.stringify({ + [a]: { ck: { id: 'ck', name: 'carol@example.org', email: 'carol@example.org', type: 'individual' } }, + [b]: { cj: { id: 'cj', name: 'bob@example.org', email: 'bob@example.org', type: 'individual' } }, + })); + useAuthStore.getState().removeAccount(b); + expect(Object.keys(JSON.parse(localStorage.getItem('bulwark-delegate-names')!))).toEqual([a]); + }); + it('leaves its folder open in the Pro sidebar, so deleting the rows shown deletes nothing in the active login', async () => { const row = message(); useEmailStore.setState({ diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 03e20a93c..41b83b341 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -20,6 +20,7 @@ import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; +import { forgetPrincipals } from '@/lib/delegation/known-principals'; import { useSearchHistoryStore } from './search-history-store'; import { useCalendarNotificationStore } from './calendar-notification-store'; import { usePolicyStore } from './policy-store'; @@ -2230,6 +2231,7 @@ export const useAuthStore = create()( clients.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId); + forgetPrincipals(accountId); if (!wasDemoMode) clearPluginStorageForAccount(accountId); } @@ -2368,6 +2370,7 @@ export const useAuthStore = create()( evictAccount(accountId); forgetAccountRows(accountId, get().client); accountStore.removeAccount(accountId); + forgetPrincipals(accountId); clearPluginStorageForAccount(accountId); },