From e62553642fb8849061a4e16dd87d23a0105c34bd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:32:58 +0200 Subject: [PATCH] feat: tell plugins about sign-in, token renewal and account changes --- lib/__tests__/plugin-hook-catalog.test.ts | 2 +- .../__tests__/auth-store-plugin-hooks.test.ts | 72 +++++++++++++++++++ stores/account-store.ts | 4 ++ stores/auth-store.ts | 22 ++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 stores/__tests__/auth-store-plugin-hooks.test.ts diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index b3ce7ee0a..5d5dc3173 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ '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', () => { diff --git a/stores/__tests__/auth-store-plugin-hooks.test.ts b/stores/__tests__/auth-store-plugin-hooks.test.ts new file mode 100644 index 000000000..6c8072ae0 --- /dev/null +++ b/stores/__tests__/auth-store-plugin-hooks.test.ts @@ -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 }); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index d43fe42c3..2a6a37059 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -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()( 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()( defaultAccountId: newDefault, }; }); + void authHooks.onAccountRemove.emit({ accountId }); }, setActiveAccount: (accountId) => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 389a05234..6961a0064 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1250,6 +1250,11 @@ function performFullLogout(set: (state: Partial) => 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 { return Promise.race([ authHooks.onBeforeLogout.emit({ accountId }), @@ -1508,6 +1513,7 @@ export const useAuthStore = create()( }); }).catch(() => {}); + notifySignedIn(get()); return true; } catch (error) { debug.error('Login error:', error); @@ -1622,6 +1628,7 @@ export const useAuthStore = create()( }).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()( activeAccountId: demoAccountId, isDemoMode: true, }); + notifySignedIn(get()); return true; } catch (error) { debug.error('Demo login error:', error); @@ -1830,6 +1838,7 @@ export const useAuthStore = create()( 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()( }); }).catch(() => {}); + notifySignedIn(get()); return true; } catch (error) { debug.error('Server SSO login error:', error); @@ -2061,6 +2071,7 @@ export const useAuthStore = create()( 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 }); + } +});