diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 3b5d22bc..5aac3a0c 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 5174850d..cd082de6 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/*) ─────────────────────────── /** @@ -786,6 +791,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 9036027f..a2de80cd 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 accbf1fc..b93543a1 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 8d0606eb..fe2efb80 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 4316ba42..f302a0cc 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(() => {});