Merge pull request #724 from paulhenry46/userlogout-api-hook
feat: implement existing hooks onBeforeLogout and onAfterLogout + new plugin API method
This commit is contained in:
@@ -83,6 +83,7 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
|||||||
'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
|
'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).' },
|
'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: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: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.' },
|
'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.' },
|
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// user
|
// user
|
||||||
'user.getAccounts': 'account:read',
|
'user.getAccounts': 'account:read',
|
||||||
'user.getIdentities': 'identity:read',
|
'user.getIdentities': 'identity:read',
|
||||||
|
'user.logout': 'auth:emit',
|
||||||
// admin
|
// admin
|
||||||
'admin.getConfig': 'admin:config',
|
'admin.getConfig': 'admin:config',
|
||||||
'admin.getAllConfig': 'admin:config',
|
'admin.getAllConfig': 'admin:config',
|
||||||
@@ -177,7 +178,7 @@ interface AccountResponse {
|
|||||||
function doUserGetAccounts(): AccountResponse[] {
|
function doUserGetAccounts(): AccountResponse[] {
|
||||||
const state = useAccountStore.getState();
|
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) => ({
|
const accounts = state.accounts.map((account) => ({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
label: account.label,
|
label: account.label,
|
||||||
@@ -197,6 +198,10 @@ function doUserGetIdentities(): Identity[] {
|
|||||||
return useIdentityStore.getState().identities;
|
return useIdentityStore.getState().identities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doUserLogout(): Promise<void>{
|
||||||
|
return useAuthStore.getState().logout();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── http.post (same-origin /api/*) ───────────────────────────
|
// ─── http.post (same-origin /api/*) ───────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -786,6 +791,7 @@ export async function dispatchApiCall(
|
|||||||
|
|
||||||
case 'user.getAccounts': return doUserGetAccounts();
|
case 'user.getAccounts': return doUserGetAccounts();
|
||||||
case 'user.getIdentities': return doUserGetIdentities();
|
case 'user.getIdentities': return doUserGetIdentities();
|
||||||
|
case 'user.logout' : return doUserLogout();
|
||||||
|
|
||||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||||
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
user: {
|
user: {
|
||||||
getAccounts: () => callApi('user.getAccounts', []),
|
getAccounts: () => callApi('user.getAccounts', []),
|
||||||
getIdentities: () => callApi('user.getIdentities', []),
|
getIdentities: () => callApi('user.getIdentities', []),
|
||||||
|
logout: () => callApi('user.logout', []),
|
||||||
},
|
},
|
||||||
http: {
|
http: {
|
||||||
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
||||||
|
|||||||
@@ -1035,6 +1035,7 @@ export const ALL_PERMISSIONS = [
|
|||||||
'settings:read', 'settings:write',
|
'settings:read', 'settings:write',
|
||||||
'security:read',
|
'security:read',
|
||||||
'auth:observe',
|
'auth:observe',
|
||||||
|
'auth:emit',
|
||||||
'account:read',
|
'account:read',
|
||||||
'http:post', 'http:fetch',
|
'http:post', 'http:fetch',
|
||||||
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe('auth-store logout redirects', () => {
|
|||||||
vi.useRealTimers();
|
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 () => ({}) });
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||||
@@ -49,7 +49,7 @@ describe('auth-store logout redirects', () => {
|
|||||||
window.history.pushState({}, '', '/fr/calendar');
|
window.history.pushState({}, '', '/fr/calendar');
|
||||||
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic' });
|
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic' });
|
||||||
|
|
||||||
useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
|
|
||||||
expect(replaceSpy).toHaveBeenCalledWith('/fr/login');
|
expect(replaceSpy).toHaveBeenCalledWith('/fr/login');
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true });
|
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.
|
// Refresh goes in flight, then the user signs out before it settles.
|
||||||
const pending = useAuthStore.getState().refreshAccessToken();
|
const pending = useAuthStore.getState().refreshAccessToken();
|
||||||
useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
resolveInFlight!({ ok: false, status: 503, json: async () => ({}) });
|
resolveInFlight!({ ok: false, status: 503, json: async () => ({}) });
|
||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
|
|||||||
+30
-4
@@ -17,6 +17,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
|
|||||||
import { notifyParent } from '@/lib/iframe-bridge';
|
import { notifyParent } from '@/lib/iframe-bridge';
|
||||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
||||||
import type { Identity } from '@/lib/jmap/types';
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
import { authHooks } from '@/lib/plugin-hooks';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -42,8 +43,8 @@ interface AuthState {
|
|||||||
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||||
loginDemo: () => Promise<boolean>;
|
loginDemo: () => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => void;
|
logoutAll: () => Promise<void>;
|
||||||
removeAccount: (accountId: string) => void;
|
removeAccount: (accountId: string) => void;
|
||||||
switchAccount: (accountId: string) => Promise<void>;
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
@@ -1196,7 +1197,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const wasDemoMode = state.isDemoMode;
|
const wasDemoMode = state.isDemoMode;
|
||||||
const wasOAuth = state.authMode === 'oauth';
|
const wasOAuth = state.authMode === 'oauth';
|
||||||
@@ -1206,6 +1207,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const slot = account?.cookieSlot ?? 0;
|
const slot = account?.cookieSlot ?? 0;
|
||||||
|
|
||||||
// Stop refresh timers immediately
|
// Stop refresh timers immediately
|
||||||
|
|
||||||
|
const ok = await authHooks.onBeforeLogout.intercept({
|
||||||
|
accountId: accountId ?? 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!ok){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearRefreshTimer(accountId ?? undefined);
|
clearRefreshTimer(accountId ?? undefined);
|
||||||
|
|
||||||
// Disconnect and null out the client BEFORE clearing stores so the
|
// Disconnect and null out the client BEFORE clearing stores so the
|
||||||
@@ -1223,6 +1233,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
useSettingsStore.getState().disableSync();
|
useSettingsStore.getState().disableSync();
|
||||||
|
|
||||||
|
await authHooks.onAfterLogout.emit({
|
||||||
|
accountId: accountId ?? 'all',
|
||||||
|
});
|
||||||
|
|
||||||
// Check if there are remaining accounts to switch to
|
// Check if there are remaining accounts to switch to
|
||||||
const remainingAccounts = accountStore.accounts;
|
const remainingAccounts = accountStore.accounts;
|
||||||
|
|
||||||
@@ -1320,7 +1334,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logoutAll: () => {
|
logoutAll: async () => {
|
||||||
|
const ok = await authHooks.onBeforeLogout.intercept({
|
||||||
|
accountId: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!ok){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Disconnect all clients
|
// Disconnect all clients
|
||||||
for (const c of clients.values()) {
|
for (const c of clients.values()) {
|
||||||
c.disconnect();
|
c.disconnect();
|
||||||
@@ -1338,6 +1360,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accountStore.removeAccount(account.id);
|
accountStore.removeAccount(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await authHooks.onAfterLogout.emit({
|
||||||
|
accountId: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
// Background cookie/token cleanup
|
// Background cookie/token cleanup
|
||||||
apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
apiFetch('/api/auth/session?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
apiFetch('/api/auth/token?all=true', { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user