From 66ff523553272d1f0409df51c9ae3e211fa8272d Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Wed, 29 Jul 2026 13:14:09 +0200 Subject: [PATCH 1/2] feat(plugins): add 2 new methods : user.getAccounts and user.getIdentities --- lib/plugin-sandbox/consent.ts | 1 + lib/plugin-sandbox/host-api.ts | 45 +++++++++++++++++++++++++++++++++- lib/plugin-sandbox/runtime.tsx | 4 +++ lib/plugin-types.ts | 1 + 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index d89417327..3b5d22bc5 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -87,6 +87,7 @@ const PERMISSION_LABELS: Record = { 'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' }, 'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' }, 'ui:download-file': { title: 'Download files', body: 'Download custom files generated by the plugin.' }, + 'account:read': { title: 'Read your accounts', body: 'Access the list of accounts you have configured in the app.' }, }; export function describePermission(perm: string): { title: string; body: string } { diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 56e268cc0..3757228bc 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -6,6 +6,8 @@ import type { InstalledPlugin, Permission } from '../plugin-types'; import { IMPLICIT_PERMISSIONS } from '../plugin-types'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { useIdentityStore } from '@/stores/identity-store'; import { useEmailStore } from '@/stores/email-store'; import { useFilterStore } from '@/stores/filter-store'; import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; @@ -14,7 +16,7 @@ import { apiFetch } from '../browser-navigation'; import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog'; import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; -import { ContactCard } from '../jmap/types'; +import { ContactCard, Identity } from '../jmap/types'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -62,6 +64,9 @@ const PERM_PER_METHOD: Record = { 'contact.update': 'contacts:write', 'contact.create': 'contacts:write', 'contact.search': 'contacts:read', + // user + 'user.getAccounts': 'account:read', + 'user.getIdentities': 'identity:read', // admin 'admin.getConfig': 'admin:config', 'admin.getAllConfig': 'admin:config', @@ -157,6 +162,41 @@ function storageKeys(pluginId: string): string[] { return out; } +// ─── user ───────────────────────────────────────────────────── +interface AccountResponse { + id: string; + label: string; + serverUrl: string; + username: string; + displayName: string; + email: string; + avatarColor: string; + isConnected: boolean; + isDefault: boolean; +} +function doUserGetAccounts(): AccountResponse[] { + const state = useAccountStore.getState(); + + // ws remove sensitive fields from the account entries before returning to the plugin + const accounts = state.accounts.map((account) => ({ + id: account.id, + label: account.label, + serverUrl: account.serverUrl, + username: account.username, + displayName: account.displayName, + email: account.email, + avatarColor: account.avatarColor, + isConnected: account.isConnected, + isDefault: account.isDefault, + })); + + return accounts; +} + +function doUserGetIdentities(): Identity[] { + return useIdentityStore.getState().identities; +} + // ─── http.post (same-origin /api/*) ─────────────────────────── /** @@ -745,6 +785,9 @@ export async function dispatchApiCall( case 'contact.create': return doContactCreate(args[0] as ContactCard); case 'contact.search': return doContactSearch(args[0] as string); + case 'user.getAccounts': return doUserGetAccounts(); + case 'user.getIdentities': return doUserGetIdentities(); + case 'admin.getConfig': return adminGet(plugin.id, args[0] as string); case 'admin.getAllConfig': return adminGetAll(plugin.id); case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 5721079ab..2b526f613 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -175,6 +175,10 @@ function buildPluginApi(manifest: PluginManifest) { remove: (key: string) => callApi('storage.remove', [key]), keys: () => callApi('storage.keys', []), }, + user: { + getAccounts: () => callApi('user.getAccounts', []), + getIdentities: () => callApi('user.getIdentities', []), + }, http: { post: (path: string, body: Record) => callApi('http.post', [path, body]), fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index f7fb9442f..accbf1fc4 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1035,6 +1035,7 @@ export const ALL_PERMISSIONS = [ 'settings:read', 'settings:write', 'security:read', 'auth:observe', + 'account:read', 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:email-details', From d15c58f8da710f95571d092c0e08035b5b78422f Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Sat, 1 Aug 2026 20:47:22 +0200 Subject: [PATCH 2/2] feat: implement logout hook and add a new plugin api method to perform logout --- lib/plugin-sandbox/consent.ts | 1 + lib/plugin-sandbox/host-api.ts | 8 ++++- lib/plugin-sandbox/runtime.tsx | 1 + lib/plugin-types.ts | 1 + stores/__tests__/auth-store-logout.test.ts | 6 ++-- stores/auth-store.ts | 34 +++++++++++++++++++--- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 3b5d22bc5..5aac3a0ca 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -83,6 +83,7 @@ const PERMISSION_LABELS: Record = { 'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' }, 'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' }, 'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' }, + 'auth:emit': { title: 'Emit login events', body: 'Emit auth events such as logout.' }, 'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' }, 'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' }, 'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' }, diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 3757228bc..8db008809 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -67,6 +67,7 @@ const PERM_PER_METHOD: Record = { // user 'user.getAccounts': 'account:read', 'user.getIdentities': 'identity:read', + 'user.logout': 'auth:emit', // admin 'admin.getConfig': 'admin:config', 'admin.getAllConfig': 'admin:config', @@ -177,7 +178,7 @@ interface AccountResponse { function doUserGetAccounts(): AccountResponse[] { const state = useAccountStore.getState(); - // ws remove sensitive fields from the account entries before returning to the plugin + // we remove sensitive fields from the account entries before returning to the plugin const accounts = state.accounts.map((account) => ({ id: account.id, label: account.label, @@ -197,6 +198,10 @@ function doUserGetIdentities(): Identity[] { return useIdentityStore.getState().identities; } +async function doUserLogout(): Promise{ + return useAuthStore.getState().logout(); +} + // ─── http.post (same-origin /api/*) ─────────────────────────── /** @@ -787,6 +792,7 @@ export async function dispatchApiCall( case 'user.getAccounts': return doUserGetAccounts(); case 'user.getIdentities': return doUserGetIdentities(); + case 'user.logout' : return doUserLogout(); case 'admin.getConfig': return adminGet(plugin.id, args[0] as string); case 'admin.getAllConfig': return adminGetAll(plugin.id); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 2b526f613..2064a37ff 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -178,6 +178,7 @@ function buildPluginApi(manifest: PluginManifest) { user: { getAccounts: () => callApi('user.getAccounts', []), getIdentities: () => callApi('user.getIdentities', []), + logout: () => callApi('user.logout', []), }, http: { post: (path: string, body: Record) => callApi('http.post', [path, body]), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index accbf1fc4..b93543a12 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1035,6 +1035,7 @@ export const ALL_PERMISSIONS = [ 'settings:read', 'settings:write', 'security:read', 'auth:observe', + 'auth:emit', 'account:read', 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', diff --git a/stores/__tests__/auth-store-logout.test.ts b/stores/__tests__/auth-store-logout.test.ts index 8d0606ebc..fe2efb802 100644 --- a/stores/__tests__/auth-store-logout.test.ts +++ b/stores/__tests__/auth-store-logout.test.ts @@ -41,7 +41,7 @@ describe('auth-store logout redirects', () => { vi.useRealTimers(); }); - it('redirects full logout to the locale login page', () => { + it('redirects full logout to the locale login page', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); vi.stubGlobal('fetch', fetchMock); const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); @@ -49,7 +49,7 @@ describe('auth-store logout redirects', () => { window.history.pushState({}, '', '/fr/calendar'); useAuthStore.setState({ isAuthenticated: true, authMode: 'basic' }); - useAuthStore.getState().logout(); + await useAuthStore.getState().logout(); expect(replaceSpy).toHaveBeenCalledWith('/fr/login'); expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true }); @@ -169,7 +169,7 @@ describe('auth-store logout redirects', () => { // Refresh goes in flight, then the user signs out before it settles. const pending = useAuthStore.getState().refreshAccessToken(); - useAuthStore.getState().logout(); + await useAuthStore.getState().logout(); resolveInFlight!({ ok: false, status: 503, json: async () => ({}) }); await pending; diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 4316ba428..f302a0ccd 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -17,6 +17,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro import { notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; +import { authHooks } from '@/lib/plugin-hooks'; interface AuthState { isAuthenticated: boolean; @@ -42,8 +43,8 @@ interface AuthState { loginWithServerSso: (code: string, state: string) => Promise; loginDemo: () => Promise; refreshAccessToken: () => Promise; - logout: () => void; - logoutAll: () => void; + logout: () => Promise; + logoutAll: () => Promise; removeAccount: (accountId: string) => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; @@ -1196,7 +1197,7 @@ export const useAuthStore = create()( return promise; }, - logout: () => { + logout: async () => { const state = get(); const wasDemoMode = state.isDemoMode; const wasOAuth = state.authMode === 'oauth'; @@ -1206,6 +1207,15 @@ export const useAuthStore = create()( const slot = account?.cookieSlot ?? 0; // Stop refresh timers immediately + + const ok = await authHooks.onBeforeLogout.intercept({ + accountId: accountId ?? 'all', + }); + + if(!ok){ + return; + } + clearRefreshTimer(accountId ?? undefined); // Disconnect and null out the client BEFORE clearing stores so the @@ -1223,6 +1233,10 @@ export const useAuthStore = create()( useSettingsStore.getState().disableSync(); + await authHooks.onAfterLogout.emit({ + accountId: accountId ?? 'all', + }); + // Check if there are remaining accounts to switch to const remainingAccounts = accountStore.accounts; @@ -1320,7 +1334,15 @@ export const useAuthStore = create()( } }, - logoutAll: () => { + logoutAll: async () => { + const ok = await authHooks.onBeforeLogout.intercept({ + accountId: 'all' + }); + + if(!ok){ + return; + } + // Disconnect all clients for (const c of clients.values()) { c.disconnect(); @@ -1338,6 +1360,10 @@ export const useAuthStore = create()( accountStore.removeAccount(account.id); } + await authHooks.onAfterLogout.emit({ + accountId: 'all' + }); + // Background cookie/token cleanup apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {}); apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});