fix: ask the next user again before running plugins installed by someone who signed out

This commit is contained in:
Linus Rath
2026-10-26 15:15:58 +01:00
parent 8a3351bbc9
commit f829d4a2e8
3 changed files with 66 additions and 1 deletions
+32 -1
View File
@@ -16,6 +16,7 @@ vi.mock('@/lib/plugin-storage', () => ({
getPreview: vi.fn().mockResolvedValue(null),
deletePreview: vi.fn().mockResolvedValue(undefined),
},
fileStorage: { clearFiles: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock('@/lib/plugin-validator', () => ({
@@ -61,7 +62,8 @@ import { register as registerActive, deregister as deregisterActive, get as getA
// Import after mocks
import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store';
import { pluginStorage } from '@/lib/plugin-storage';
import { forgetSlotOffers } from '@/lib/plugin-loader';
import { deactivatePlugin, forgetSlotOffers } from '@/lib/plugin-loader';
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
function resetStore() {
usePluginStore.setState({
@@ -480,6 +482,35 @@ describe('plugins the admin removed', () => {
});
});
describe('a full sign-out', () => {
// On a shared browser the next person used to run the previous user's
// plugins with that user's grants and settings, without being asked.
it("drops the user's grants and settings from plugins they installed and leaves them off", () => {
usePluginStore.setState({
plugins: [
mockPlugin({
id: 'mine', enabled: true, status: 'running', permissions: ['email:read', 'http:fetch'],
grantedPermissions: ['email:read', 'http:fetch'], settings: { token: 'a-secret' },
}),
mockPlugin({ id: 'admins', managed: true, enabled: true, status: 'running', settings: { mode: 'dark' } }),
],
});
purgeSignedOutData();
window.dispatchEvent(new Event('pagehide'));
const [mine, admins] = usePluginStore.getState().plugins;
expect(mine).toMatchObject({ id: 'mine', enabled: false, settings: {} });
expect(mine.grantedPermissions).toBeUndefined();
expect(deactivatePlugin).toHaveBeenCalledWith('mine');
expect(admins).toMatchObject({ id: 'admins', enabled: true, status: 'running', settings: { mode: 'dark' } });
const stored = JSON.parse(localStorage.getItem('plugin-storage')!).state.plugins;
expect(stored[0]).toMatchObject({ id: 'mine', enabled: false, settings: {} });
expect(stored[0].grantedPermissions).toBeUndefined();
expect(pluginStorage.deleteCode).not.toHaveBeenCalled();
});
});
describe('saving the plugin list', () => {
const writes = (setItem: { mock: { calls: unknown[][] } }) =>
setItem.mock.calls.filter(([key]) => key === 'plugin-storage') as [string, string][];
+15
View File
@@ -28,6 +28,14 @@ const SIGNED_OUT_STORAGE_KEYS = [
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
// Stores the auth store can't import (they import it) clear themselves here.
const signedOutCleanups = new Set<() => void>();
/** Run `cleanup` when everyone signs out in this tab. */
export function onFullSignOut(cleanup: () => void): void {
signedOutCleanups.add(cleanup);
}
/** Remove the accounts' leftovers from browser storage. */
export function purgeSignedOutData(): void {
if (typeof window === 'undefined') return;
@@ -40,6 +48,13 @@ export function purgeSignedOutData(): void {
}
// Attachment bytes a plugin staged for an upload that never started.
void fileStorage.clearFiles().catch(() => {});
for (const cleanup of signedOutCleanups) {
try {
cleanup();
} catch {
/* one store failing must not keep the others' data */
}
}
}
/** Tell the other tabs of this browser that everyone signed out. */
+19
View File
@@ -26,6 +26,7 @@ import { fetchServerPluginList } from '@/lib/server-plugin-list';
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import type { Permission } from '@/lib/plugin-types';
import { IS_LITE } from '@/lib/lite';
import { onFullSignOut } from '@/lib/sign-out-cleanup';
let pluginInitializationPromise: Promise<void> | null = null;
// One-time guard so we attach the locale and regional subscriptions only once.
@@ -819,3 +820,21 @@ usePluginStore.subscribe((state, prev) => {
forEachInstance(plugin.id, (instance) => instance.setSettings(next));
}
});
// A plugin the user installed runs on that user's consent and settings. Once
// everyone has signed out, the next person at this browser must be asked
// again: the code stays, the grants and settings go, and it is left off.
// The admin's plugins are the admin's choice and stay as they are.
onFullSignOut(() => {
const { plugins } = usePluginStore.getState();
for (const plugin of plugins) {
if (!plugin.managed && plugin.enabled) deactivatePlugin(plugin.id);
}
usePluginStore.setState({
plugins: plugins.map((plugin) => {
if (plugin.managed) return plugin;
const { grantedPermissions: _granted, ...rest } = plugin;
return { ...rest, enabled: false, status: 'disabled' as PluginStatus, error: undefined, settings: {} };
}),
});
});