mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins about sign-in, token renewal and account changes
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user