fix: forget delegate names on sign-out and when their account is removed

This commit is contained in:
Linus Rath
2026-10-28 06:27:28 +01:00
parent 5ce0f96f26
commit f0a89fdb65
6 changed files with 73 additions and 5 deletions
+1 -1
View File
@@ -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');
@@ -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');
});
});
+21 -4
View File
@@ -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<Principal, 'id' | 'name' | 'email' | 'type'>;
@@ -50,3 +53,17 @@ export function rememberedPrincipals(accountKey: string): Map<string, Principal>
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
}
}
+2
View File
@@ -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';
@@ -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({
+3
View File
@@ -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<AuthState>()(
clients.delete(accountId);
evictAccount(accountId);
accountStore.removeAccount(accountId);
forgetPrincipals(accountId);
if (!wasDemoMode) clearPluginStorageForAccount(accountId);
}
@@ -2368,6 +2370,7 @@ export const useAuthStore = create<AuthState>()(
evictAccount(accountId);
forgetAccountRows(accountId, get().client);
accountStore.removeAccount(accountId);
forgetPrincipals(accountId);
clearPluginStorageForAccount(accountId);
},