fix: stop plugins from blocking or stalling sign-out

This commit is contained in:
Linus Rath
2026-10-14 03:27:34 +02:00
parent 77088a6008
commit c5dc263259
2 changed files with 64 additions and 16 deletions
@@ -0,0 +1,46 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import * as browserNavigation from '@/lib/browser-navigation';
import { authHooks, clearAllHooks } from '@/lib/plugin-hooks';
import { useAuthStore, LOGOUT_HOOK_BUDGET_MS } from '../auth-store';
import { useAccountStore } from '../account-store';
describe('sign-out with plugins', () => {
beforeEach(() => {
vi.restoreAllMocks();
clearAllHooks();
sessionStorage.clear();
localStorage.clear();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: null, activeAccountId: null });
});
afterEach(() => {
vi.useRealTimers();
});
it('signs out even when a plugin answers false before sign-out', async () => {
const before = vi.fn().mockReturnValue(false);
const after = vi.fn();
authHooks.onBeforeLogout.register('p', before);
authHooks.onAfterLogout.register('p', after);
await useAuthStore.getState().logout();
expect(before).toHaveBeenCalledWith({ accountId: 'all' });
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(after).toHaveBeenCalledWith({ accountId: 'all' });
});
it('does not wait on a plugin that never answers', async () => {
vi.useFakeTimers();
authHooks.onBeforeLogout.register('p', () => new Promise(() => {}));
const done = useAuthStore.getState().logout();
await vi.advanceTimersByTimeAsync(LOGOUT_HOOK_BUDGET_MS + 10);
await done;
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
+18 -16
View File
@@ -1243,6 +1243,20 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void {
broadcastSignOut();
}
/**
* How long sign-out waits for plugins to tidy up. Plugins observe sign-out;
* they cannot stop it or hold it up, or a plugin could keep the user signed
* in on a shared computer.
*/
export const LOGOUT_HOOK_BUDGET_MS = 2000;
function beforeLogoutHooks(accountId: string): Promise<void> {
return Promise.race([
authHooks.onBeforeLogout.emit({ accountId }),
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
]);
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -2080,13 +2094,7 @@ export const useAuthStore = create<AuthState>()(
const userInitiated = options?.endProviderSession !== false;
const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account);
const ok = await authHooks.onBeforeLogout.intercept({
accountId: accountId ?? 'all',
});
if(!ok){
return;
}
await beforeLogoutHooks(accountId ?? 'all');
// From here on nothing may renew this slot's session; the cleanup
// below reopens it once the credentials are gone.
@@ -2111,7 +2119,7 @@ export const useAuthStore = create<AuthState>()(
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
await authHooks.onAfterLogout.emit({
void authHooks.onAfterLogout.emit({
accountId: accountId ?? 'all',
});
@@ -2234,13 +2242,7 @@ export const useAuthStore = create<AuthState>()(
},
logoutAll: async () => {
const ok = await authHooks.onBeforeLogout.intercept({
accountId: 'all'
});
if(!ok){
return;
}
await beforeLogoutHooks('all');
const accountStore = useAccountStore.getState();
const allAccounts = [...accountStore.accounts];
@@ -2272,7 +2274,7 @@ export const useAuthStore = create<AuthState>()(
accountStore.removeAccount(account.id);
}
await authHooks.onAfterLogout.emit({
void authHooks.onAfterLogout.emit({
accountId: 'all'
});