feat: tell plugins about sign-in, token renewal and account changes

This commit is contained in:
Linus Rath
2026-10-14 03:32:58 +02:00
parent c5dc263259
commit e62553642f
4 changed files with 99 additions and 1 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean {
const NOT_YET_WIRED = new Set<HookGroup>([
'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search',
'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities',
'filters', 'vacation', 'templates', 'auth', 'security', 'ui', 'keyboard',
'filters', 'vacation', 'templates', 'security', 'ui', 'keyboard',
]);
describe('hook catalogue', () => {
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHooks, clearAllHooks } from '@/lib/plugin-hooks';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
function listen(bus: { register: (id: string, fn: (...a: unknown[]) => unknown) => unknown }) {
const fn = vi.fn();
bus.register('test', fn);
return fn;
}
const ACCOUNT = {
label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode: 'basic' as const,
rememberMe: false, displayName: 'Alice', email: 'alice@example.org', lastLoginAt: 0,
isConnected: true, hasError: false, isDefault: true,
};
beforeEach(() => {
vi.restoreAllMocks();
clearAllHooks();
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, username: null });
});
describe('auth hooks', () => {
it('reports accounts that are added and removed, once each', () => {
const added = listen(authHooks.onAccountAdd);
const removed = listen(authHooks.onAccountRemove);
const id = useAccountStore.getState().addAccount(ACCOUNT);
useAccountStore.getState().addAccount(ACCOUNT); // already there: an update
useAccountStore.getState().removeAccount(id);
useAccountStore.getState().removeAccount(id); // already gone
expect(added.mock.calls).toEqual([[{ accountId: id }]]);
expect(removed.mock.calls).toEqual([[{ accountId: id }]]);
});
it('says when the session is ready, and when another account becomes active', () => {
const ready = listen(authHooks.onAuthReady);
const switched = listen(authHooks.onAccountSwitch);
const client = {} as never;
useAuthStore.setState({ isAuthenticated: true, client, activeAccountId: 'a' });
useAuthStore.setState({ activeAccountId: 'b' });
expect(ready.mock.calls).toEqual([[{ accountId: 'a' }]]);
expect(switched).toHaveBeenCalledWith({ accountId: 'b', previousAccountId: 'a' });
});
it('reports a renewed access token', async () => {
const refreshed = listen(authHooks.onTokenRefresh);
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true, status: 200, json: async () => ({ access_token: 'new', expires_in: 3600 }),
})));
useAuthStore.setState({ activeAccountId: 'acc', authMode: 'oauth' });
await expect(useAuthStore.getState().refreshAccessToken()).resolves.toBe('new');
expect(refreshed).toHaveBeenCalledWith({ accountId: 'acc' });
});
it('reports a sign-in', async () => {
const signedIn = listen(authHooks.onLogin);
const ok = await useAuthStore.getState().loginDemo();
expect(ok).toBe(true);
const { activeAccountId, username } = useAuthStore.getState();
expect(signedIn).toHaveBeenCalledWith({ accountId: activeAccountId, username });
});
});
+4
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
import { authHooks } from '@/lib/plugin-hooks';
export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */
@@ -130,11 +131,13 @@ export const useAccountStore = create<AccountState>()(
activeAccountId: s.activeAccountId ?? id,
defaultAccountId: isDefault ? id : s.defaultAccountId,
}));
void authHooks.onAccountAdd.emit({ accountId: id });
return id;
},
removeAccount: (accountId) => {
if (!get().accounts.some((a) => a.id === accountId)) return;
set((s) => {
const remaining = s.accounts.filter((a) => a.id !== accountId);
const wasDefault = s.defaultAccountId === accountId;
@@ -158,6 +161,7 @@ export const useAccountStore = create<AccountState>()(
defaultAccountId: newDefault,
};
});
void authHooks.onAccountRemove.emit({ accountId });
},
setActiveAccount: (accountId) => {
+22
View File
@@ -1250,6 +1250,11 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void {
*/
export const LOGOUT_HOOK_BUDGET_MS = 2000;
function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void {
if (!state.activeAccountId) return;
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
}
function beforeLogoutHooks(accountId: string): Promise<void> {
return Promise.race([
authHooks.onBeforeLogout.emit({ accountId }),
@@ -1508,6 +1513,7 @@ export const useAuthStore = create<AuthState>()(
});
}).catch(() => {});
notifySignedIn(get());
return true;
} catch (error) {
debug.error('Login error:', error);
@@ -1622,6 +1628,7 @@ export const useAuthStore = create<AuthState>()(
}).catch(() => {});
resumeSettingsSync({ id: accountId, username, serverUrl });
notifySignedIn(get());
return true;
} catch (error) {
debug.error('Token login error:', error);
@@ -1687,6 +1694,7 @@ export const useAuthStore = create<AuthState>()(
activeAccountId: demoAccountId,
isDemoMode: true,
});
notifySignedIn(get());
return true;
} catch (error) {
debug.error('Demo login error:', error);
@@ -1830,6 +1838,7 @@ export const useAuthStore = create<AuthState>()(
sessionStorage.removeItem('oauth_cookie_slot');
}
notifySignedIn(get());
return true;
} catch (error) {
debug.error('OAuth login error:', error);
@@ -1967,6 +1976,7 @@ export const useAuthStore = create<AuthState>()(
});
}).catch(() => {});
notifySignedIn(get());
return true;
} catch (error) {
debug.error('Server SSO login error:', error);
@@ -2061,6 +2071,7 @@ export const useAuthStore = create<AuthState>()(
resetRefreshBackoff(accountId ?? undefined);
scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
if (accountId) void authHooks.onTokenRefresh.emit({ accountId });
return access_token;
} catch (error) {
// Network failure (offline, Wi-Fi switch, server unreachable) -
@@ -3059,3 +3070,14 @@ if (typeof window !== 'undefined') {
if (useAuthStore.getState().isAuthenticated) navigateToLogin();
});
}
// Plugins hear when a session becomes usable (after start-up or a sign-in)
// and when another account becomes active, whichever path got there.
useAuthStore.subscribe((state, prev) => {
const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId;
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
}
});