Rewrite the JMAP client, the stores and the JMAP types #125
No files matched your search
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Schema } from '@/types/schema';
|
||||
import { objectChecks } from './usePermissions';
|
||||
|
||||
const schema = {
|
||||
objects: {
|
||||
'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' },
|
||||
'x:Smtp': { type: 'singleton', description: '', permissionPrefix: 'sysSmtp' },
|
||||
'x:DomainView': { type: 'view', objectName: 'x:Domain' },
|
||||
'x:Loop': { type: 'view', objectName: 'x:DomainView' },
|
||||
},
|
||||
} as unknown as Schema;
|
||||
|
||||
const granted = new Set([
|
||||
'sysDomainGet',
|
||||
'sysDomainCreate',
|
||||
'sysDomainDestroy',
|
||||
'sysSmtpGet',
|
||||
'sysSmtpUpdate',
|
||||
'sysSmtpCreate',
|
||||
'sysSmtpDestroy',
|
||||
]);
|
||||
const can = objectChecks(schema, (prefix, action) => granted.has(`${prefix}${action}`));
|
||||
|
||||
describe('objectChecks', () => {
|
||||
it('checks the permission of the object a view shows', () => {
|
||||
expect(can.canViewObject('x:Domain')).toBe(true);
|
||||
expect(can.canViewObject('x:DomainView')).toBe(true);
|
||||
expect(can.canUpdateObject('x:DomainView')).toBe(false);
|
||||
expect(can.canCreateObject('x:DomainView')).toBe(true);
|
||||
expect(can.canDestroyObject('x:Domain')).toBe(true);
|
||||
});
|
||||
|
||||
it('never creates or destroys a singleton', () => {
|
||||
expect(can.canUpdateObject('x:Smtp')).toBe(true);
|
||||
expect(can.canCreateObject('x:Smtp')).toBe(false);
|
||||
expect(can.canDestroyObject('x:Smtp')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows nothing on unknown views, views of views, or before the schema loads', () => {
|
||||
expect(can.canViewObject('x:Nope')).toBe(false);
|
||||
expect(can.canViewObject('x:Loop')).toBe(false);
|
||||
const none = objectChecks(null, () => true);
|
||||
expect([
|
||||
none.canViewObject('x:Domain'),
|
||||
none.canCreateObject('x:Domain'),
|
||||
none.canUpdateObject('x:Domain'),
|
||||
none.canDestroyObject('x:Domain'),
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
+28
-56
@@ -1,67 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
type Action = 'Get' | 'Query' | 'Create' | 'Update' | 'Destroy';
|
||||
|
||||
/**
|
||||
* What the admin may do with the object behind a view. A view is checked
|
||||
* against the object it shows; a singleton always exists once, so it is
|
||||
* never created or destroyed. Nothing is allowed until the schema loads.
|
||||
*/
|
||||
export function objectChecks(schema: Schema | null, allowed: (prefix: string, action: Action) => boolean) {
|
||||
const may = (viewName: string, action: Action) => {
|
||||
const resolved = schema && resolveObject(schema, viewName);
|
||||
if (!resolved) return false;
|
||||
if ((action === 'Create' || action === 'Destroy') && resolved.objectType.type === 'singleton') return false;
|
||||
return allowed(resolved.permissionPrefix, action);
|
||||
};
|
||||
return {
|
||||
canViewObject: (viewName: string) => may(viewName, 'Get'),
|
||||
canCreateObject: (viewName: string) => may(viewName, 'Create'),
|
||||
canUpdateObject: (viewName: string) => may(viewName, 'Update'),
|
||||
canDestroyObject: (viewName: string) => may(viewName, 'Destroy'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in admin's permissions, and the per-view checks built on them. */
|
||||
export function usePermissions() {
|
||||
const { permissions, edition, hasPermission, hasObjectPermission } = useAccountStore();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
|
||||
function canViewObject(viewName: string): boolean {
|
||||
if (!schema) return false;
|
||||
const resolved = resolveObject(schema, viewName);
|
||||
if (!resolved) return false;
|
||||
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
||||
}
|
||||
|
||||
function canCreateObject(viewName: string): boolean {
|
||||
if (!schema) return false;
|
||||
const resolved = resolveObject(schema, viewName);
|
||||
if (!resolved) return false;
|
||||
if (resolved.objectType.type === 'singleton') return false;
|
||||
return hasObjectPermission(resolved.permissionPrefix, 'Create');
|
||||
}
|
||||
|
||||
function canUpdateObject(viewName: string): boolean {
|
||||
if (!schema) return false;
|
||||
const resolved = resolveObject(schema, viewName);
|
||||
if (!resolved) return false;
|
||||
return hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||
}
|
||||
|
||||
function canDestroyObject(viewName: string): boolean {
|
||||
if (!schema) return false;
|
||||
const resolved = resolveObject(schema, viewName);
|
||||
if (!resolved) return false;
|
||||
if (resolved.objectType.type === 'singleton') return false;
|
||||
return hasObjectPermission(resolved.permissionPrefix, 'Destroy');
|
||||
}
|
||||
|
||||
function isEnterpriseHidden(enterprise?: boolean): boolean {
|
||||
if (!enterprise) return false;
|
||||
return edition === 'oss';
|
||||
}
|
||||
|
||||
function isEnterpriseDisabled(enterprise?: boolean): boolean {
|
||||
if (!enterprise) return false;
|
||||
return edition === 'community';
|
||||
}
|
||||
|
||||
return {
|
||||
permissions,
|
||||
edition,
|
||||
hasPermission,
|
||||
hasObjectPermission,
|
||||
canViewObject,
|
||||
canCreateObject,
|
||||
canUpdateObject,
|
||||
canDestroyObject,
|
||||
isEnterpriseHidden,
|
||||
isEnterpriseDisabled,
|
||||
};
|
||||
return { permissions, edition, hasPermission, hasObjectPermission, ...objectChecks(schema, hasObjectPermission) };
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('@/services/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
|
||||
|
||||
const memory = new Map<string, string>();
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void memory.set(k, v),
|
||||
removeItem: (k: string) => void memory.delete(k),
|
||||
clear: () => memory.clear(),
|
||||
});
|
||||
|
||||
const { useAuthStore } = await import('@/stores/authStore');
|
||||
const client = await import('./client');
|
||||
|
||||
/** Answers each request in turn with these method responses. */
|
||||
function answer(...pages: unknown[][]) {
|
||||
for (const methodResponses of pages) {
|
||||
apiFetch.mockResolvedValueOnce({ json: async () => ({ methodResponses, sessionState: 's' }) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The JSON body of the nth request. */
|
||||
const body = (n = 0) => JSON.parse(apiFetch.mock.calls[n][1].body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
client.setRegistryWriteListener(null);
|
||||
useAuthStore.setState({
|
||||
primaryAccountId: 'p',
|
||||
activeAccountId: 'a',
|
||||
apiUrl: null,
|
||||
maxObjectsInGet: 500,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountId', () => {
|
||||
it('puts registry types on the primary account and the rest on the active one', () => {
|
||||
expect(client.getAccountId('x:Domain')).toBe('p');
|
||||
expect(client.getAccountId('Mailbox')).toBe('a');
|
||||
});
|
||||
|
||||
it('says which account is missing', () => {
|
||||
useAuthStore.setState({ primaryAccountId: null, activeAccountId: null });
|
||||
expect(() => client.getAccountId('x:Domain')).toThrow('No primary account ID available');
|
||||
expect(() => client.getAccountId('Mailbox')).toThrow('No active account ID available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jmapRequest', () => {
|
||||
it('posts the calls with the core capabilities and any extra ones', async () => {
|
||||
answer([['Mailbox/get', { list: [] }, '0']]);
|
||||
const calls: [string, Record<string, unknown>, string][] = [['Mailbox/get', { accountId: 'a' }, '0']];
|
||||
const responses = await client.jmapRequest(calls, undefined, ['urn:example']);
|
||||
expect(responses).toEqual([['Mailbox/get', { list: [] }, '0']]);
|
||||
const [path, init] = apiFetch.mock.calls[0];
|
||||
expect(path).toBe('/jmap');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
|
||||
expect(body().methodCalls).toEqual(calls);
|
||||
expect(body().using).toContain('urn:ietf:params:jmap:core');
|
||||
expect(body().using).toContain('urn:inbuxa:jmap:registry');
|
||||
expect(body().using.at(-1)).toBe('urn:example');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://mail.example/api/jmap/', '/api/jmap/'],
|
||||
['/custom/jmap', '/custom/jmap'],
|
||||
['https://[broken', '/jmap'],
|
||||
])('sends to the path of apiUrl %s', async (apiUrl, path) => {
|
||||
useAuthStore.setState({ apiUrl });
|
||||
answer([]);
|
||||
await client.jmapRequest([]);
|
||||
expect(apiFetch.mock.calls[0][0]).toBe(path);
|
||||
});
|
||||
|
||||
it('passes the abort signal through', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
answer([]);
|
||||
await client.jmapRequest([], signal);
|
||||
expect(apiFetch.mock.calls[0][1].signal).toBe(signal);
|
||||
});
|
||||
|
||||
it('tells the listener about registry writes, with what they changed', async () => {
|
||||
const listener = { started: vi.fn(), finished: vi.fn() };
|
||||
client.setRegistryWriteListener(listener);
|
||||
answer([['x:Domain/set', { updated: { d1: null } }, '0']]);
|
||||
await client.jmapSet('x:Domain', 'p', { update: { d1: { name: 'x' } } });
|
||||
expect(listener.started).toHaveBeenCalledTimes(1);
|
||||
expect(listener.finished).toHaveBeenCalledWith([expect.objectContaining({ objectName: 'x:Domain' })]);
|
||||
});
|
||||
|
||||
it('still finishes the listener when the request fails', async () => {
|
||||
const listener = { started: vi.fn(), finished: vi.fn() };
|
||||
client.setRegistryWriteListener(listener);
|
||||
apiFetch.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(client.jmapSet('x:Domain', 'p', { destroy: ['d1'] })).rejects.toThrow('offline');
|
||||
expect(listener.finished).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('leaves the listener out of reads', async () => {
|
||||
const listener = { started: vi.fn(), finished: vi.fn() };
|
||||
client.setRegistryWriteListener(listener);
|
||||
answer([['x:Domain/get', { list: [] }, '0']]);
|
||||
await client.jmapGet('x:Domain', 'p', null);
|
||||
expect(listener.started).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shaped calls', () => {
|
||||
it('gets some or all objects, with properties only when asked', async () => {
|
||||
answer([], []);
|
||||
await client.jmapGet('Mailbox', 'a', null);
|
||||
await client.jmapGet('Mailbox', 'a', ['m1'], ['name']);
|
||||
expect(body(0).methodCalls).toEqual([['Mailbox/get', { accountId: 'a', ids: null }, '0']]);
|
||||
expect(body(1).methodCalls).toEqual([['Mailbox/get', { accountId: 'a', ids: ['m1'], properties: ['name'] }, '0']]);
|
||||
});
|
||||
|
||||
it('sets with create, update and destroy in one call', async () => {
|
||||
answer([]);
|
||||
await client.jmapSet('x:Domain', 'p', { create: { n: { name: 'a' } }, destroy: ['d2'] });
|
||||
expect(body().methodCalls).toEqual([
|
||||
['x:Domain/set', { accountId: 'p', create: { n: { name: 'a' } }, destroy: ['d2'] }, '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('queries and gets the found ids by back-reference', async () => {
|
||||
answer([]);
|
||||
await client.jmapQueryAndGet('x:Account', 'p', { limit: 10 }, ['name']);
|
||||
expect(body().methodCalls).toEqual([
|
||||
['x:Account/query', { accountId: 'p', limit: 10 }, '0'],
|
||||
[
|
||||
'x:Account/get',
|
||||
{ accountId: 'p', '#ids': { resultOf: '0', name: 'x:Account/query', path: '/ids' }, properties: ['name'] },
|
||||
'1',
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jmapGetBatched', () => {
|
||||
it('splits the ids by maxObjectsInGet and joins the lists', async () => {
|
||||
useAuthStore.setState({ maxObjectsInGet: 2 });
|
||||
answer([['T/get', { list: [{ id: '1' }, { id: '2' }] }, '0']], [['T/get', { list: [{ id: '3' }] }, '0']]);
|
||||
const list = await client.jmapGetBatched('T', 'a', ['1', '2', '3']);
|
||||
expect(list.map((o) => o.id)).toEqual(['1', '2', '3']);
|
||||
expect(body(0).methodCalls[0][1].ids).toEqual(['1', '2']);
|
||||
expect(body(1).methodCalls[0][1].ids).toEqual(['3']);
|
||||
});
|
||||
|
||||
it('sends nothing for no ids', async () => {
|
||||
expect(await client.jmapGetBatched('T', 'a', [])).toEqual([]);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws with the error type', async () => {
|
||||
answer([['error', { type: 'forbidden' }, '0']]);
|
||||
await expect(client.jmapGetBatched('T', 'a', ['1'])).rejects.toThrow('JMAP T/get error: forbidden');
|
||||
});
|
||||
|
||||
it('stops once aborted', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
expect(await client.jmapGetBatched('T', 'a', ['1'], undefined, abort.signal)).toEqual([]);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('jmapQueryAll', () => {
|
||||
it('pages on from the last id until an empty page', async () => {
|
||||
answer(
|
||||
[['T/query', { ids: ['1', '2'], limit: 2 }, '0']],
|
||||
[['T/query', { ids: ['3'], limit: 2 }, '0']],
|
||||
[['T/query', { ids: [], limit: 2 }, '0']],
|
||||
);
|
||||
const ids = await client.jmapQueryAll('T', 'a', { position: 0, limit: 2 });
|
||||
expect(ids).toEqual(['1', '2', '3']);
|
||||
expect(body(0).methodCalls[0][1]).toEqual({ accountId: 'a', position: 0, limit: 2 });
|
||||
expect(body(1).methodCalls[0][1]).toEqual({ accountId: 'a', limit: 2, anchor: '2', anchorOffset: 1 });
|
||||
expect(body(2).methodCalls[0][1].anchor).toBe('3');
|
||||
});
|
||||
|
||||
it('stops when the server sets no limit', async () => {
|
||||
answer([['T/query', { ids: ['1'] }, '0']]);
|
||||
expect(await client.jmapQueryAll('T', 'a')).toEqual(['1']);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws with the error type', async () => {
|
||||
answer([['error', {}, '0']]);
|
||||
await expect(client.jmapQueryAll('T', 'a')).rejects.toThrow('JMAP T/query error: unknown');
|
||||
});
|
||||
|
||||
it('gets everything a query matches, skipping the get when nothing does', async () => {
|
||||
answer([['T/query', { ids: ['1'] }, '0']], [['T/get', { list: [{ id: '1' }] }, '0']]);
|
||||
expect(await client.jmapQueryAllAndGet('T', 'a')).toEqual({ ids: ['1'], list: [{ id: '1' }] });
|
||||
apiFetch.mockReset();
|
||||
answer([['T/query', { ids: [] }, '0']]);
|
||||
expect(await client.jmapQueryAllAndGet('T', 'a')).toEqual({ ids: [], list: [] });
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server info', () => {
|
||||
it.each([
|
||||
['fetchSession', '/jmap/session'],
|
||||
['fetchSchema', '/api/schema'],
|
||||
['fetchAccountInfo', '/api/account'],
|
||||
] as const)('%s reads %s', async (name, path) => {
|
||||
apiFetch.mockResolvedValueOnce({ json: async () => ({ ok: true }) });
|
||||
expect(await client[name]()).toEqual({ ok: true });
|
||||
expect(apiFetch).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
+111
-172
@@ -1,20 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { apiFetch } from '@/services/api';
|
||||
/**
|
||||
* JMAP (RFC 8620) over apiFetch: one request of method calls, and helpers
|
||||
* for the common shapes (get, set, query then get, everything a query
|
||||
* matches). Settings live on the primary account as `x:` registry objects;
|
||||
* mail, calendars and the like live on the account being looked at.
|
||||
*/
|
||||
import { logJmapExchange } from '@/lib/debug';
|
||||
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||
import type { Schema } from '@/types/schema';
|
||||
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
|
||||
|
||||
const JMAP_USING = [
|
||||
/** The capabilities every request declares; callers can add more. */
|
||||
const CAPABILITIES = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:inbuxa:jmap:registry',
|
||||
'urn:ietf:params:jmap:blob',
|
||||
@@ -26,104 +30,86 @@ const JMAP_USING = [
|
||||
'urn:ietf:params:jmap:vacationresponse',
|
||||
];
|
||||
|
||||
/** The account an object type lives on: the primary account for `x:` registry objects, else the active one. */
|
||||
export function getAccountId(objectType: string): string {
|
||||
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
||||
if (objectType.startsWith('x:')) {
|
||||
if (!primaryAccountId) throw new Error('No primary account ID available');
|
||||
return primaryAccountId;
|
||||
}
|
||||
if (!activeAccountId) throw new Error('No active account ID available');
|
||||
return activeAccountId;
|
||||
const registry = objectType.startsWith('x:');
|
||||
const id = registry ? primaryAccountId : activeAccountId;
|
||||
if (!id) throw new Error(registry ? 'No primary account ID available' : 'No active account ID available');
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa: told about every registry write, so that saved settings can be
|
||||
* applied on the server (settingsApplyStore). `started` comes before the
|
||||
* request goes out and `finished` after it settles, with the types it changed
|
||||
* and what the server said about applying them.
|
||||
* Told about every request that writes the registry, so saved settings can
|
||||
* be applied on the server (settingsApplyStore): `started` before it goes
|
||||
* out, `finished` once it settles, with what it changed.
|
||||
*/
|
||||
export interface RegistryWriteListener {
|
||||
started(): void;
|
||||
finished(writes: RegistryWrite[]): void;
|
||||
}
|
||||
|
||||
let registryWriteListener: RegistryWriteListener | null = null;
|
||||
let registryListener: RegistryWriteListener | null = null;
|
||||
|
||||
export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
|
||||
registryWriteListener = listener;
|
||||
registryListener = listener;
|
||||
}
|
||||
|
||||
/** The JMAP API's path on the server, from the session's apiUrl. */
|
||||
function apiPath(): string {
|
||||
const { apiUrl } = useAuthStore.getState();
|
||||
if (!apiUrl) return '/jmap';
|
||||
if (!/^https?:\/\//.test(apiUrl)) return apiUrl;
|
||||
// Absolute: keep only the path, since apiFetch puts the server's address in front.
|
||||
return URL.canParse(apiUrl) ? new URL(apiUrl).pathname : '/jmap';
|
||||
}
|
||||
|
||||
async function send(calls: JmapMethodCall[], signal?: AbortSignal, extraCapabilities: string[] = []) {
|
||||
const started = performance.now();
|
||||
const res = await apiFetch(apiPath(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: [...CAPABILITIES, ...extraCapabilities], methodCalls: calls }),
|
||||
signal,
|
||||
});
|
||||
const { methodResponses } = (await res.json()) as JmapResponse;
|
||||
logJmapExchange(calls, methodResponses, performance.now() - started);
|
||||
if (import.meta.env.DEV) {
|
||||
for (const [name, args, callId] of methodResponses) {
|
||||
if (name === 'error') console.error(`JMAP error [${callId}]:`, args);
|
||||
}
|
||||
}
|
||||
return methodResponses;
|
||||
}
|
||||
|
||||
/** Sends method calls in one request and returns the method responses, in order. */
|
||||
export async function jmapRequest(
|
||||
methodCalls: JmapMethodCall[],
|
||||
signal?: AbortSignal,
|
||||
extraUsing: string[] = [],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
|
||||
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
|
||||
const listener = writesRegistry(methodCalls) ? registryListener : null;
|
||||
if (!listener) return send(methodCalls, signal, extraUsing);
|
||||
listener.started();
|
||||
let written: RegistryWrite[] = [];
|
||||
let writes: RegistryWrite[] = [];
|
||||
try {
|
||||
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
|
||||
written = registryWrites(responses);
|
||||
const responses = await send(methodCalls, signal, extraUsing);
|
||||
writes = registryWrites(responses);
|
||||
return responses;
|
||||
} finally {
|
||||
listener.finished(written);
|
||||
listener.finished(writes);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendJmapRequest(
|
||||
methodCalls: JmapMethodCall[],
|
||||
signal?: AbortSignal,
|
||||
extraUsing: string[] = [],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const { apiUrl } = useAuthStore.getState();
|
||||
let path = apiUrl || '/jmap';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
try {
|
||||
path = new URL(path).pathname;
|
||||
} catch {
|
||||
path = '/jmap';
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const response = await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
using: [...JMAP_USING, ...extraUsing],
|
||||
methodCalls,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as JmapResponse;
|
||||
|
||||
logJmapExchange(methodCalls, data.methodResponses, performance.now() - startedAt);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
for (const [methodName, result, callId] of data.methodResponses) {
|
||||
if (methodName === 'error') {
|
||||
console.error(`JMAP error [${callId}]:`, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data.methodResponses;
|
||||
}
|
||||
|
||||
export async function jmapGet(
|
||||
/** `<type>/get` for some ids, or all of them (null). */
|
||||
export function jmapGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
ids: string[] | null,
|
||||
properties?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ids };
|
||||
if (properties) {
|
||||
args.properties = properties;
|
||||
}
|
||||
return jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||
return jmapRequest([[`${objectType}/get`, { accountId, ids, ...(properties && { properties }) }, '0']], signal);
|
||||
}
|
||||
|
||||
interface JmapQueryOptions {
|
||||
@@ -136,59 +122,39 @@ interface JmapQueryOptions {
|
||||
calculateTotal?: boolean;
|
||||
}
|
||||
|
||||
export async function jmapQuery(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
options: JmapQueryOptions = {},
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ...options };
|
||||
return jmapRequest([[`${objectType}/query`, args, '0']]);
|
||||
}
|
||||
|
||||
interface JmapSetOptions {
|
||||
create?: Record<string, Record<string, unknown>>;
|
||||
update?: Record<string, Record<string, unknown>>;
|
||||
destroy?: string[];
|
||||
}
|
||||
|
||||
export async function jmapSet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
options: JmapSetOptions = {},
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ...options };
|
||||
return jmapRequest([[`${objectType}/set`, args, '0']]);
|
||||
/** `<type>/set`: create, update and destroy in one call. */
|
||||
export function jmapSet(objectType: string, accountId: string, options: JmapSetOptions = {}) {
|
||||
return jmapRequest([[`${objectType}/set`, { accountId, ...options }, '0']]);
|
||||
}
|
||||
|
||||
export async function jmapQueryAndGet(
|
||||
/** A page of `<type>/query` and the objects it found, in one request. */
|
||||
export function jmapQueryAndGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
queryOptions: JmapQueryOptions = {},
|
||||
properties?: string[],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
...queryOptions,
|
||||
};
|
||||
|
||||
const getArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: '0',
|
||||
name: `${objectType}/query`,
|
||||
path: '/ids',
|
||||
},
|
||||
};
|
||||
if (properties) {
|
||||
getArgs.properties = properties;
|
||||
}
|
||||
|
||||
const foundIds = { resultOf: '0', name: `${objectType}/query`, path: '/ids' };
|
||||
return jmapRequest([
|
||||
[`${objectType}/query`, queryArgs, '0'],
|
||||
[`${objectType}/get`, getArgs, '1'],
|
||||
[`${objectType}/query`, { accountId, ...queryOptions }, '0'],
|
||||
[`${objectType}/get`, { accountId, '#ids': foundIds, ...(properties && { properties }) }, '1'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** The arguments of a single response, or an error naming the call that failed. */
|
||||
function onlyResult(responses: JmapMethodResponse[], call: string): Record<string, unknown> {
|
||||
const [name, args] = responses[0];
|
||||
if (name === 'error') throw new Error(`JMAP ${call} error: ${(args as Record<string, unknown>).type ?? 'unknown'}`);
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The objects with these ids, fetched in as many requests as the server's maxObjectsInGet asks. */
|
||||
export async function jmapGetBatched(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
@@ -196,67 +162,50 @@ export async function jmapGetBatched(
|
||||
properties?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const batchSize = useAuthStore.getState().maxObjectsInGet;
|
||||
const allItems: Record<string, unknown>[] = [];
|
||||
|
||||
for (let offset = 0; offset < ids.length; offset += batchSize) {
|
||||
if (signal?.aborted) break;
|
||||
const batchIds = ids.slice(offset, offset + batchSize);
|
||||
const args: Record<string, unknown> = { accountId, ids: batchIds };
|
||||
if (properties) args.properties = properties;
|
||||
const responses = await jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||
const result = responses[0];
|
||||
if (result[0] === 'error') {
|
||||
throw new Error(`JMAP ${objectType}/get error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||
}
|
||||
const list = (result[1] as { list?: Record<string, unknown>[] }).list ?? [];
|
||||
allItems.push(...list);
|
||||
const perRequest = useAuthStore.getState().maxObjectsInGet;
|
||||
const found: Record<string, unknown>[] = [];
|
||||
for (let at = 0; at < ids.length && !signal?.aborted; at += perRequest) {
|
||||
const responses = await jmapGet(objectType, accountId, ids.slice(at, at + perRequest), properties, signal);
|
||||
const { list } = onlyResult(responses, `${objectType}/get`) as { list?: Record<string, unknown>[] };
|
||||
found.push(...(list ?? []));
|
||||
}
|
||||
|
||||
return allItems;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Most pages a query is followed for, a guard against a server that never says it's done. */
|
||||
const MAX_PAGES = 1000;
|
||||
|
||||
/**
|
||||
* Every id a query matches, page after page. Each page starts after the last
|
||||
* id of the one before (anchor, offset 1). It stops at an empty page, or
|
||||
* when the server's answer has no limit, meaning it sent everything.
|
||||
*/
|
||||
export async function jmapQueryAll(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
queryOptions: JmapQueryOptions = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const allIds: string[] = [];
|
||||
let anchor: string | undefined;
|
||||
const MAX_PAGES = 1000;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const ids: string[] = [];
|
||||
let after: string | undefined;
|
||||
for (let page = 0; page < MAX_PAGES && !signal?.aborted; page++) {
|
||||
const args: Record<string, unknown> = { accountId, ...queryOptions };
|
||||
if (anchor) {
|
||||
if (after) {
|
||||
delete args.position;
|
||||
args.anchor = anchor;
|
||||
args.anchorOffset = 1;
|
||||
Object.assign(args, { anchor: after, anchorOffset: 1 });
|
||||
}
|
||||
|
||||
const responses = await jmapRequest([[`${objectType}/query`, args, '0']], signal);
|
||||
const result = responses[0];
|
||||
if (result[0] === 'error') {
|
||||
throw new Error(`JMAP ${objectType}/query error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
const data = result[1] as unknown as JmapQueryResponse;
|
||||
const pageIds = data.ids ?? [];
|
||||
const result = onlyResult(responses, `${objectType}/query`) as unknown as JmapQueryResponse;
|
||||
const pageIds = result.ids ?? [];
|
||||
if (pageIds.length === 0) break;
|
||||
|
||||
allIds.push(...pageIds);
|
||||
|
||||
if (data.limit === undefined || data.limit === null) break;
|
||||
|
||||
anchor = pageIds[pageIds.length - 1];
|
||||
ids.push(...pageIds);
|
||||
if (result.limit == null) break;
|
||||
after = pageIds[pageIds.length - 1];
|
||||
}
|
||||
|
||||
return allIds;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Everything a query matches: the ids, and the objects. */
|
||||
export async function jmapQueryAllAndGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
@@ -265,28 +214,18 @@ export async function jmapQueryAllAndGet(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ ids: string[]; list: Record<string, unknown>[] }> {
|
||||
const ids = await jmapQueryAll(objectType, accountId, queryOptions, signal);
|
||||
if (ids.length === 0) return { ids, list: [] };
|
||||
const list = await jmapGetBatched(objectType, accountId, ids, properties, signal);
|
||||
const list = ids.length ? await jmapGetBatched(objectType, accountId, ids, properties, signal) : [];
|
||||
return { ids, list };
|
||||
}
|
||||
|
||||
export async function fetchSession(): Promise<Record<string, unknown>> {
|
||||
const response = await apiFetch('/jmap/session');
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
const getJson = async <T>(path: string): Promise<T> => (await apiFetch(path)).json() as Promise<T>;
|
||||
|
||||
export async function fetchSchema(): Promise<Schema> {
|
||||
const response = await apiFetch('/api/schema');
|
||||
return response.json() as Promise<Schema>;
|
||||
}
|
||||
/** The JMAP session: accounts, capabilities, the API URL. */
|
||||
export const fetchSession = () => getJson<Record<string, unknown>>('/jmap/session');
|
||||
|
||||
interface AccountInfoResponse {
|
||||
permissions: string[];
|
||||
edition: 'enterprise' | 'community' | 'oss';
|
||||
locale: string;
|
||||
}
|
||||
/** The server's description of every setting, which builds the settings pages. */
|
||||
export const fetchSchema = () => getJson<Schema>('/api/schema');
|
||||
|
||||
export async function fetchAccountInfo(): Promise<AccountInfoResponse> {
|
||||
const response = await apiFetch('/api/account');
|
||||
return response.json() as Promise<AccountInfoResponse>;
|
||||
}
|
||||
/** The signed-in admin's permissions, the server's edition and the admin's locale. */
|
||||
export const fetchAccountInfo = () =>
|
||||
getJson<{ permissions: string[]; edition: 'enterprise' | 'community' | 'oss'; locale: string }>('/api/account');
|
||||
+20
-101
@@ -1,115 +1,34 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAccountStore } from './accountStore';
|
||||
|
||||
const account = () => useAccountStore.getState();
|
||||
|
||||
beforeEach(() => account().setAccountInfo([], 'community', 'en'));
|
||||
|
||||
describe('accountStore', () => {
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({
|
||||
permissions: [],
|
||||
edition: 'community',
|
||||
locale: 'en',
|
||||
});
|
||||
it('keeps the permissions and the locale', () => {
|
||||
account().setAccountInfo(['sysAccountGet'], 'community', 'de');
|
||||
expect(account()).toMatchObject({ permissions: ['sysAccountGet'], locale: 'de' });
|
||||
});
|
||||
|
||||
describe('setAccountInfo', () => {
|
||||
it('stores permissions, edition, and locale', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'enterprise', 'de');
|
||||
|
||||
const state = useAccountStore.getState();
|
||||
expect(state.permissions).toEqual(['sysAccountGet', 'sysAccountCreate']);
|
||||
expect(state.edition).toBe('enterprise');
|
||||
expect(state.locale).toBe('de');
|
||||
});
|
||||
|
||||
it('replaces previous values on subsequent calls', () => {
|
||||
const { setAccountInfo } = useAccountStore.getState();
|
||||
setAccountInfo(['a'], 'enterprise', 'fr');
|
||||
setAccountInfo(['b', 'c'], 'oss', 'ja');
|
||||
|
||||
const state = useAccountStore.getState();
|
||||
expect(state.permissions).toEqual(['b', 'c']);
|
||||
// INBUXA: one edition. A server reporting 'oss' still unlocks everything.
|
||||
expect(state.edition).toBe('enterprise');
|
||||
expect(state.locale).toBe('ja');
|
||||
});
|
||||
|
||||
it('accepts empty permissions array', () => {
|
||||
useAccountStore.getState().setAccountInfo([], 'community', 'en');
|
||||
expect(useAccountStore.getState().permissions).toEqual([]);
|
||||
});
|
||||
it.each(['oss', 'community', 'enterprise'] as const)('treats a %s server as having every feature', (edition) => {
|
||||
account().setAccountInfo([], edition, 'en');
|
||||
expect(account().edition).toBe('enterprise');
|
||||
});
|
||||
|
||||
describe('hasPermission', () => {
|
||||
it('returns true when the permission exists', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'community', 'en');
|
||||
|
||||
expect(useAccountStore.getState().hasPermission('sysAccountGet')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the permission does not exist', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
|
||||
|
||||
expect(useAccountStore.getState().hasPermission('sysAccountDestroy')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when permissions are empty', () => {
|
||||
expect(useAccountStore.getState().hasPermission('anything')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasObjectPermission', () => {
|
||||
it('builds correct permission string from prefix and action', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
|
||||
|
||||
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Get')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-matching action', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
|
||||
|
||||
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Destroy')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-matching prefix', () => {
|
||||
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
|
||||
|
||||
expect(useAccountStore.getState().hasObjectPermission('sysDomain', 'Get')).toBe(false);
|
||||
});
|
||||
|
||||
it('works with all action types', () => {
|
||||
useAccountStore.getState().setAccountInfo(['fooCreate', 'fooUpdate', 'fooDestroy', 'fooGet'], 'community', 'en');
|
||||
|
||||
const state = useAccountStore.getState();
|
||||
expect(state.hasObjectPermission('foo', 'Create')).toBe(true);
|
||||
expect(state.hasObjectPermission('foo', 'Update')).toBe(true);
|
||||
expect(state.hasObjectPermission('foo', 'Destroy')).toBe(true);
|
||||
expect(state.hasObjectPermission('foo', 'Get')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edition', () => {
|
||||
it('defaults to community', () => {
|
||||
expect(useAccountStore.getState().edition).toBe('community');
|
||||
});
|
||||
|
||||
it('can be changed to enterprise', () => {
|
||||
useAccountStore.getState().setAccountInfo([], 'enterprise', 'en');
|
||||
expect(useAccountStore.getState().edition).toBe('enterprise');
|
||||
});
|
||||
|
||||
it('is never oss or community, whatever the server reports (INBUXA: one edition)', () => {
|
||||
useAccountStore.getState().setAccountInfo([], 'oss', 'en');
|
||||
expect(useAccountStore.getState().edition).toBe('enterprise');
|
||||
useAccountStore.getState().setAccountInfo([], 'community', 'en');
|
||||
expect(useAccountStore.getState().edition).toBe('enterprise');
|
||||
});
|
||||
it('answers permission checks from the list', () => {
|
||||
account().setAccountInfo(['sysDomainGet', 'sysDomainUpdate', 'impersonate'], 'oss', 'en');
|
||||
expect(account().hasPermission('impersonate')).toBe(true);
|
||||
expect(account().hasPermission('sysDomainGet')).toBe(true);
|
||||
expect(account().hasPermission('sysDomainDestroy')).toBe(false);
|
||||
expect(account().hasObjectPermission('sysDomain', 'Update')).toBe(true);
|
||||
expect(account().hasObjectPermission('sysDomain', 'Create')).toBe(false);
|
||||
expect(account().hasObjectPermission('sysAccount', 'Get')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,14 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Edition = 'enterprise' | 'community' | 'oss';
|
||||
|
||||
/** The signed-in admin, from the server's account endpoint. */
|
||||
interface AccountState {
|
||||
permissions: string[];
|
||||
edition: Edition;
|
||||
@@ -18,6 +16,7 @@ interface AccountState {
|
||||
|
||||
setAccountInfo: (permissions: string[], edition: Edition, locale: string) => void;
|
||||
hasPermission: (perm: string) => boolean;
|
||||
/** An object permission is its type's prefix and the action, e.g. `sysDomainUpdate`. */
|
||||
hasObjectPermission: (prefix: string, action: 'Get' | 'Query' | 'Create' | 'Update' | 'Destroy') => boolean;
|
||||
}
|
||||
|
||||
@@ -26,18 +25,11 @@ export const useAccountStore = create<AccountState>()((set, get) => ({
|
||||
edition: 'community',
|
||||
locale: 'en',
|
||||
|
||||
setAccountInfo: (permissions, _edition, locale) => {
|
||||
// INBUXA has one edition, with every feature. Whatever the server reports,
|
||||
// nothing is hidden or disabled as Enterprise-only. Features the server
|
||||
// hasn't rebuilt yet answer that for themselves.
|
||||
set({ permissions, edition: 'enterprise', locale });
|
||||
},
|
||||
// inbuxa has one edition with every feature, whatever the server reports,
|
||||
// so nothing is hidden or disabled as Enterprise-only.
|
||||
setAccountInfo: (permissions, _reported, locale) => set({ permissions, edition: 'enterprise', locale }),
|
||||
|
||||
hasPermission: (perm) => {
|
||||
return get().permissions.includes(perm);
|
||||
},
|
||||
hasPermission: (perm) => get().permissions.includes(perm),
|
||||
|
||||
hasObjectPermission: (prefix, action) => {
|
||||
return get().permissions.includes(`${prefix}${action}`);
|
||||
},
|
||||
hasObjectPermission: (prefix, action) => get().hasPermission(prefix + action),
|
||||
}));
|
||||
+139
-207
@@ -1,234 +1,166 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useCacheStore } from './cacheStore';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const initialState = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
tokenEndpoint: null,
|
||||
accounts: {},
|
||||
primaryAccountId: null,
|
||||
activeAccountId: null,
|
||||
apiUrl: null,
|
||||
};
|
||||
const memory = new Map<string, string>();
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void memory.set(k, v),
|
||||
removeItem: (k: string) => void memory.delete(k),
|
||||
clear: () => memory.clear(),
|
||||
});
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState(initialState);
|
||||
useCacheStore.getState().clearAll();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const { useAuthStore } = await import('./authStore');
|
||||
const { useCacheStore } = await import('./cacheStore');
|
||||
|
||||
describe('setTokens', () => {
|
||||
it('stores access and refresh tokens', () => {
|
||||
useAuthStore.getState().setTokens('acc-123', 'ref-456', 3600, 'https://auth/token');
|
||||
const accounts = { p: { name: 'admin', isPersonal: true }, b: { name: 'shared', isPersonal: false } };
|
||||
const auth = () => useAuthStore.getState();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.accessToken).toBe('acc-123');
|
||||
expect(state.refreshToken).toBe('ref-456');
|
||||
});
|
||||
beforeEach(() => {
|
||||
auth().logout();
|
||||
useAuthStore.setState({ maxObjectsInGet: 500, maxObjectsInSet: 500 });
|
||||
useCacheStore.getState().clearAll();
|
||||
memory.clear();
|
||||
});
|
||||
|
||||
it('computes expiration time from expiresIn', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://auth/token');
|
||||
|
||||
expect(useAuthStore.getState().tokenExpiresAt).toBe(now + 3600 * 1000);
|
||||
});
|
||||
|
||||
it('stores token endpoint', () => {
|
||||
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://example.com/token');
|
||||
|
||||
expect(useAuthStore.getState().tokenEndpoint).toBe('https://example.com/token');
|
||||
describe('tokens', () => {
|
||||
it('keeps the tokens and works out when they expire', () => {
|
||||
vi.useFakeTimers({ now: 1_000_000 });
|
||||
auth().setTokens('acc', 'ref', 3600, '/token', '/logout');
|
||||
expect(auth()).toMatchObject({
|
||||
accessToken: 'acc',
|
||||
refreshToken: 'ref',
|
||||
tokenExpiresAt: 1_000_000 + 3_600_000,
|
||||
tokenEndpoint: '/token',
|
||||
endSessionEndpoint: '/logout',
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('returns false when no token is set', () => {
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when token exists and is not expired', () => {
|
||||
const now = Date.now();
|
||||
useAuthStore.setState({
|
||||
accessToken: 'tok',
|
||||
tokenExpiresAt: now + 60_000,
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when token is expired', () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: 'tok',
|
||||
tokenExpiresAt: Date.now() - 1000,
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when accessToken is null even if expiry is in the future', () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
tokenExpiresAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
||||
});
|
||||
it('has no sign-out endpoint unless given one', () => {
|
||||
auth().setTokens('acc', 'ref', 60, '/token', '/logout');
|
||||
auth().setTokens('acc', 'ref', 60, '/token');
|
||||
expect(auth().endSessionEndpoint).toBeNull();
|
||||
});
|
||||
|
||||
describe('isTokenExpiringSoon', () => {
|
||||
it('returns false when tokenExpiresAt is null', () => {
|
||||
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true within 60 seconds of expiration', () => {
|
||||
useAuthStore.setState({ tokenExpiresAt: Date.now() + 30_000 });
|
||||
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when more than 60 seconds remain', () => {
|
||||
useAuthStore.setState({ tokenExpiresAt: Date.now() + 120_000 });
|
||||
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when token is already expired', () => {
|
||||
useAuthStore.setState({ tokenExpiresAt: Date.now() - 5000 });
|
||||
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
|
||||
});
|
||||
it('is signed in only with a token that has not expired', () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
expect(auth().isAuthenticated()).toBe(false);
|
||||
auth().setTokens('acc', 'ref', 10, '/token');
|
||||
expect(auth().isAuthenticated()).toBe(true);
|
||||
vi.setSystemTime(10_000);
|
||||
expect(auth().isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
describe('setSession', () => {
|
||||
it('stores accounts and primaryAccountId', () => {
|
||||
const accounts = {
|
||||
'acc-1': { name: 'Personal', isPersonal: true },
|
||||
'acc-2': { name: 'Work', isPersonal: false },
|
||||
};
|
||||
it('counts a token as expiring inside its last minute', () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
expect(auth().isTokenExpiringSoon()).toBe(false);
|
||||
auth().setTokens('acc', 'ref', 120, '/token');
|
||||
vi.setSystemTime(60_000);
|
||||
expect(auth().isTokenExpiringSoon()).toBe(false);
|
||||
vi.setSystemTime(60_001);
|
||||
expect(auth().isTokenExpiringSoon()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api');
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.accounts).toEqual(accounts);
|
||||
expect(state.primaryAccountId).toBe('acc-1');
|
||||
expect(state.apiUrl).toBe('https://api');
|
||||
});
|
||||
|
||||
it('sets activeAccountId to primaryAccountId', () => {
|
||||
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
|
||||
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('preserves an activeAccountId that exists in the new accounts', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'acc-2' });
|
||||
|
||||
useAuthStore.getState().setSession(
|
||||
{
|
||||
'acc-1': { name: 'Personal', isPersonal: true },
|
||||
'acc-2': { name: 'Group', isPersonal: false },
|
||||
},
|
||||
'acc-1',
|
||||
'https://api',
|
||||
);
|
||||
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('falls back to primaryAccountId when the previous activeAccountId is unknown', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'gone' });
|
||||
|
||||
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
|
||||
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||
});
|
||||
describe('session', () => {
|
||||
it('starts on the primary account', () => {
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
expect(auth()).toMatchObject({ accounts, primaryAccountId: 'p', activeAccountId: 'p', apiUrl: '/jmap' });
|
||||
});
|
||||
|
||||
describe('switchAccount', () => {
|
||||
it('changes activeAccountId when account exists', () => {
|
||||
useAuthStore.setState({
|
||||
accounts: {
|
||||
a1: { name: 'A1', isPersonal: true },
|
||||
a2: { name: 'A2', isPersonal: false },
|
||||
},
|
||||
activeAccountId: 'a1',
|
||||
});
|
||||
|
||||
useAuthStore.getState().switchAccount('a2');
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('a2');
|
||||
});
|
||||
|
||||
it('does nothing for unknown account id', () => {
|
||||
useAuthStore.setState({
|
||||
accounts: { a1: { name: 'A1', isPersonal: true } },
|
||||
activeAccountId: 'a1',
|
||||
});
|
||||
|
||||
useAuthStore.getState().switchAccount('nonexistent');
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('a1');
|
||||
});
|
||||
|
||||
it('clears cached objects when the account changes', () => {
|
||||
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||
useCacheStore.getState().setObjectList('Mailbox', [{ id: 'm1', label: 'Inbox' }]);
|
||||
useAuthStore.setState({
|
||||
accounts: {
|
||||
a1: { name: 'A1', isPersonal: true },
|
||||
a2: { name: 'A2', isPersonal: false },
|
||||
},
|
||||
activeAccountId: 'a1',
|
||||
});
|
||||
|
||||
useAuthStore.getState().switchAccount('a2');
|
||||
|
||||
expect(useCacheStore.getState().displayNames).toEqual({});
|
||||
expect(useCacheStore.getState().objectLists).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps the cache when switching to the already active account', () => {
|
||||
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||
useAuthStore.setState({
|
||||
accounts: { a1: { name: 'A1', isPersonal: true } },
|
||||
activeAccountId: 'a1',
|
||||
});
|
||||
|
||||
useAuthStore.getState().switchAccount('a1');
|
||||
|
||||
expect(useCacheStore.getState().getDisplayName('Mailbox', 'm1')).toBe('Inbox');
|
||||
});
|
||||
it('stays on the account being looked at while it still exists', () => {
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
auth().switchAccount('b');
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
expect(auth().activeAccountId).toBe('b');
|
||||
auth().setSession({ p: accounts.p }, 'p', '/jmap');
|
||||
expect(auth().activeAccountId).toBe('p');
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears all state', () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: 'tok',
|
||||
refreshToken: 'ref',
|
||||
tokenExpiresAt: 99999,
|
||||
tokenEndpoint: 'https://auth/token',
|
||||
accounts: { a: { name: 'A', isPersonal: true } },
|
||||
primaryAccountId: 'a',
|
||||
activeAccountId: 'a',
|
||||
apiUrl: 'https://api',
|
||||
});
|
||||
it('takes the object limits only when the server gives them', () => {
|
||||
auth().setSession(accounts, 'p', '/jmap', 50, 20);
|
||||
expect(auth()).toMatchObject({ maxObjectsInGet: 50, maxObjectsInSet: 20 });
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
expect(auth()).toMatchObject({ maxObjectsInGet: 50, maxObjectsInSet: 20 });
|
||||
});
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
it('empties the cache when switching to another known account', () => {
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
const cache = useCacheStore.getState();
|
||||
cache.setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||
cache.setObjectList('Mailbox', [{ id: 'm1', label: 'Inbox' }]);
|
||||
auth().switchAccount('b');
|
||||
expect(auth().activeAccountId).toBe('b');
|
||||
expect(useCacheStore.getState()).toMatchObject({ displayNames: {}, objectLists: {} });
|
||||
});
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.accessToken).toBeNull();
|
||||
expect(state.refreshToken).toBeNull();
|
||||
expect(state.tokenExpiresAt).toBeNull();
|
||||
expect(state.tokenEndpoint).toBeNull();
|
||||
expect(state.accounts).toEqual({});
|
||||
expect(state.primaryAccountId).toBeNull();
|
||||
expect(state.activeAccountId).toBeNull();
|
||||
expect(state.apiUrl).toBeNull();
|
||||
it('ignores a switch to an unknown or the same account', () => {
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||
auth().switchAccount('p');
|
||||
auth().switchAccount('nope');
|
||||
expect(auth().activeAccountId).toBe('p');
|
||||
expect(useCacheStore.getState().getDisplayName('Mailbox', 'm1')).toBe('Inbox');
|
||||
});
|
||||
|
||||
it('keeps who is signed in', () => {
|
||||
auth().setUsername('admin');
|
||||
expect(auth().username).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('forgets the tokens, the session and the user', () => {
|
||||
auth().setTokens('acc', 'ref', 60, '/token', '/logout');
|
||||
auth().setSession(accounts, 'p', '/jmap', 50);
|
||||
auth().setUsername('admin');
|
||||
auth().logout();
|
||||
expect(auth()).toMatchObject({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
tokenEndpoint: null,
|
||||
endSessionEndpoint: null,
|
||||
accounts: {},
|
||||
primaryAccountId: null,
|
||||
activeAccountId: null,
|
||||
apiUrl: null,
|
||||
username: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('keeps the tokens and the active account in session storage, and nothing else', () => {
|
||||
auth().setTokens('acc', 'ref', 60, '/token', '/logout');
|
||||
auth().setSession(accounts, 'p', '/jmap');
|
||||
auth().setUsername('admin');
|
||||
const saved = JSON.parse(memory.get('inbuxa-auth')!);
|
||||
expect(Object.keys(saved.state).sort()).toEqual(
|
||||
[
|
||||
'accessToken',
|
||||
'activeAccountId',
|
||||
'endSessionEndpoint',
|
||||
'refreshToken',
|
||||
'tokenEndpoint',
|
||||
'tokenExpiresAt',
|
||||
].sort(),
|
||||
);
|
||||
expect(saved.state).toMatchObject({ accessToken: 'acc', activeAccountId: 'p', endSessionEndpoint: '/logout' });
|
||||
});
|
||||
|
||||
it('reads them back', async () => {
|
||||
memory.set(
|
||||
'inbuxa-auth',
|
||||
JSON.stringify({ state: { accessToken: 'saved', refreshToken: 'r', activeAccountId: 'b' }, version: 0 }),
|
||||
);
|
||||
await useAuthStore.persist.rehydrate();
|
||||
expect(auth()).toMatchObject({ accessToken: 'saved', refreshToken: 'r', activeAccountId: 'b' });
|
||||
});
|
||||
});
|
||||
+58
-70
@@ -1,35 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { useCacheStore } from '@/stores/cacheStore';
|
||||
|
||||
/** An account in the JMAP session. */
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
/** The sign-in, and the JMAP session it opened. */
|
||||
interface Signin {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
/** When the access token stops working, in epoch milliseconds. */
|
||||
tokenExpiresAt: number | null;
|
||||
tokenEndpoint: string | null;
|
||||
/** Where sign-out sends the browser, when the server offers it. */
|
||||
endSessionEndpoint: string | null;
|
||||
accounts: Record<string, AccountInfo>;
|
||||
/** The admin's own account, which holds the `x:` settings. */
|
||||
primaryAccountId: string | null;
|
||||
/** The account being looked at, for mail and the like. */
|
||||
activeAccountId: string | null;
|
||||
apiUrl: string | null;
|
||||
/** Who is signed in, from the session's `username`. */
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
interface AuthState extends Signin {
|
||||
/** The server's limits on objects per get and set; the session can lower or raise them. */
|
||||
maxObjectsInGet: number;
|
||||
maxObjectsInSet: number;
|
||||
/** INBUXA: who is signed in, from the JMAP session (`username`). */
|
||||
username: string | null;
|
||||
|
||||
setTokens: (
|
||||
access: string,
|
||||
@@ -49,73 +56,65 @@ interface AuthState {
|
||||
setUsername: (username: string | null) => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: () => boolean;
|
||||
/** Under a minute left, so the next request refreshes first. */
|
||||
isTokenExpiringSoon: () => boolean;
|
||||
}
|
||||
|
||||
const signedOut: Signin = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
tokenEndpoint: null,
|
||||
endSessionEndpoint: null,
|
||||
accounts: {},
|
||||
primaryAccountId: null,
|
||||
activeAccountId: null,
|
||||
apiUrl: null,
|
||||
username: null,
|
||||
};
|
||||
|
||||
const REFRESH_AHEAD_MS = 60_000;
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
tokenEndpoint: null,
|
||||
endSessionEndpoint: null,
|
||||
accounts: {},
|
||||
primaryAccountId: null,
|
||||
activeAccountId: null,
|
||||
username: null,
|
||||
|
||||
setUsername: (username) => {
|
||||
set({ username });
|
||||
},
|
||||
apiUrl: null,
|
||||
...signedOut,
|
||||
maxObjectsInGet: 500,
|
||||
maxObjectsInSet: 500,
|
||||
|
||||
setTokens: (access, refresh, expiresIn, tokenEndpoint, endSessionEndpoint) => {
|
||||
setTokens: (accessToken, refreshToken, expiresIn, tokenEndpoint, endSessionEndpoint) =>
|
||||
set({
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpiresAt: Date.now() + expiresIn * 1000,
|
||||
tokenEndpoint,
|
||||
endSessionEndpoint: endSessionEndpoint ?? null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
|
||||
const current = get().activeAccountId;
|
||||
// A reload keeps the account being looked at, if the session still has it.
|
||||
const kept = get().activeAccountId;
|
||||
set({
|
||||
accounts,
|
||||
primaryAccountId,
|
||||
activeAccountId: current && accounts[current] ? current : primaryAccountId,
|
||||
activeAccountId: kept && kept in accounts ? kept : primaryAccountId,
|
||||
apiUrl,
|
||||
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
|
||||
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
|
||||
...(maxObjectsInGet !== undefined && { maxObjectsInGet }),
|
||||
...(maxObjectsInSet !== undefined && { maxObjectsInSet }),
|
||||
});
|
||||
},
|
||||
|
||||
switchAccount: (accountId) => {
|
||||
const { accounts, activeAccountId } = get();
|
||||
if (accounts[accountId] && accountId !== activeAccountId) {
|
||||
set({ activeAccountId: accountId });
|
||||
useCacheStore.getState().clearAll();
|
||||
}
|
||||
if (accountId === activeAccountId || !(accountId in accounts)) return;
|
||||
set({ activeAccountId: accountId });
|
||||
// Names and lists belong to the account they were read from.
|
||||
useCacheStore.getState().clearAll();
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
tokenEndpoint: null,
|
||||
endSessionEndpoint: null,
|
||||
accounts: {},
|
||||
primaryAccountId: null,
|
||||
activeAccountId: null,
|
||||
apiUrl: null,
|
||||
username: null,
|
||||
});
|
||||
},
|
||||
setUsername: (username) => set({ username }),
|
||||
|
||||
logout: () => set(signedOut),
|
||||
|
||||
isAuthenticated: () => {
|
||||
const { accessToken, tokenExpiresAt } = get();
|
||||
@@ -124,32 +123,21 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
isTokenExpiringSoon: () => {
|
||||
const { tokenExpiresAt } = get();
|
||||
if (tokenExpiresAt === null) return false;
|
||||
return tokenExpiresAt - Date.now() < 60_000;
|
||||
return tokenExpiresAt !== null && tokenExpiresAt - Date.now() < REFRESH_AHEAD_MS;
|
||||
},
|
||||
}),
|
||||
{
|
||||
// Session storage: a sign-in lasts as long as the tab, and other tabs sign in for themselves.
|
||||
name: 'inbuxa-auth',
|
||||
storage: {
|
||||
getItem: (name) => {
|
||||
const value = sessionStorage.getItem(name);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
sessionStorage.setItem(name, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
sessionStorage.removeItem(name);
|
||||
},
|
||||
},
|
||||
partialize: (state) =>
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: ({ accessToken, refreshToken, tokenExpiresAt, tokenEndpoint, endSessionEndpoint, activeAccountId }) =>
|
||||
({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
tokenExpiresAt: state.tokenExpiresAt,
|
||||
tokenEndpoint: state.tokenEndpoint,
|
||||
endSessionEndpoint: state.endSessionEndpoint,
|
||||
activeAccountId: state.activeAccountId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpiresAt,
|
||||
tokenEndpoint,
|
||||
endSessionEndpoint,
|
||||
activeAccountId,
|
||||
}) as AuthState,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,94 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useCacheStore } from './cacheStore';
|
||||
|
||||
const cache = () => useCacheStore.getState();
|
||||
|
||||
beforeEach(() => cache().clearAll());
|
||||
|
||||
describe('cacheStore', () => {
|
||||
beforeEach(() => {
|
||||
useCacheStore.setState({ displayNames: {} });
|
||||
it('adds display names to what a type already has, newer names winning', () => {
|
||||
cache().setDisplayNames('x:Account', { a1: 'Alice' });
|
||||
cache().setDisplayNames('x:Account', { a2: 'Bob', a1: 'Alicia' });
|
||||
cache().setDisplayNames('x:Domain', { d1: 'example.com' });
|
||||
expect(cache().displayNames).toEqual({
|
||||
'x:Account': { a1: 'Alicia', a2: 'Bob' },
|
||||
'x:Domain': { d1: 'example.com' },
|
||||
});
|
||||
expect(cache().getDisplayName('x:Account', 'a2')).toBe('Bob');
|
||||
expect(cache().getDisplayName('x:Account', 'a9')).toBeUndefined();
|
||||
expect(cache().getDisplayName('x:Group', 'g1')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('setDisplayNames', () => {
|
||||
it('stores entries for an object type', () => {
|
||||
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice', u2: 'Bob' });
|
||||
|
||||
expect(useCacheStore.getState().displayNames).toEqual({
|
||||
user: { u1: 'Alice', u2: 'Bob' },
|
||||
});
|
||||
});
|
||||
|
||||
it('merges with existing entries for the same object type', () => {
|
||||
const { setDisplayNames } = useCacheStore.getState();
|
||||
setDisplayNames('user', { u1: 'Alice' });
|
||||
setDisplayNames('user', { u2: 'Bob' });
|
||||
|
||||
expect(useCacheStore.getState().displayNames.user).toEqual({
|
||||
u1: 'Alice',
|
||||
u2: 'Bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites individual entries when keys collide', () => {
|
||||
const { setDisplayNames } = useCacheStore.getState();
|
||||
setDisplayNames('user', { u1: 'Alice' });
|
||||
setDisplayNames('user', { u1: 'Alicia' });
|
||||
|
||||
expect(useCacheStore.getState().displayNames.user.u1).toBe('Alicia');
|
||||
});
|
||||
it('replaces an object list and drops them all at once', () => {
|
||||
cache().setObjectList('x:Domain', [{ id: 'd1', label: 'one' }]);
|
||||
cache().setObjectList('x:Domain', [{ id: 'd2', label: 'two' }]);
|
||||
cache().setObjectList('x:Role', [{ id: 'r1', label: 'admin' }]);
|
||||
expect(cache().objectLists['x:Domain']).toEqual([{ id: 'd2', label: 'two' }]);
|
||||
cache().setDisplayNames('x:Domain', { d2: 'two' });
|
||||
cache().invalidateAllObjectLists();
|
||||
expect(cache().objectLists).toEqual({});
|
||||
expect(cache().getDisplayName('x:Domain', 'd2')).toBe('two');
|
||||
});
|
||||
|
||||
describe('getDisplayName', () => {
|
||||
it('returns cached name', () => {
|
||||
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
|
||||
expect(useCacheStore.getState().getDisplayName('user', 'u1')).toBe('Alice');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown id', () => {
|
||||
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
|
||||
expect(useCacheStore.getState().getDisplayName('user', 'u99')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for unknown object type', () => {
|
||||
expect(useCacheStore.getState().getDisplayName('domain', 'd1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateCache', () => {
|
||||
it('removes entries for a specific object type', () => {
|
||||
const { setDisplayNames } = useCacheStore.getState();
|
||||
setDisplayNames('user', { u1: 'Alice' });
|
||||
setDisplayNames('domain', { d1: 'example.com' });
|
||||
|
||||
useCacheStore.getState().invalidateCache('user');
|
||||
|
||||
const state = useCacheStore.getState();
|
||||
expect(state.displayNames.user).toBeUndefined();
|
||||
expect(state.displayNames.domain).toEqual({ d1: 'example.com' });
|
||||
});
|
||||
|
||||
it('is a no-op when object type does not exist', () => {
|
||||
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
|
||||
useCacheStore.getState().invalidateCache('nonexistent');
|
||||
|
||||
expect(useCacheStore.getState().displayNames.user).toEqual({ u1: 'Alice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple object types', () => {
|
||||
it('stores different object types independently', () => {
|
||||
const { setDisplayNames } = useCacheStore.getState();
|
||||
setDisplayNames('user', { u1: 'Alice' });
|
||||
setDisplayNames('domain', { d1: 'example.com' });
|
||||
setDisplayNames('group', { g1: 'Admins' });
|
||||
|
||||
const state = useCacheStore.getState();
|
||||
expect(state.getDisplayName('user', 'u1')).toBe('Alice');
|
||||
expect(state.getDisplayName('domain', 'd1')).toBe('example.com');
|
||||
expect(state.getDisplayName('group', 'g1')).toBe('Admins');
|
||||
});
|
||||
it('clears everything', () => {
|
||||
cache().setDisplayNames('x:Domain', { d1: 'one' });
|
||||
cache().setObjectList('x:Domain', [{ id: 'd1', label: 'one' }]);
|
||||
cache().clearAll();
|
||||
expect(cache()).toMatchObject({ displayNames: {}, objectLists: {} });
|
||||
});
|
||||
});
|
||||
+18
-52
@@ -1,28 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** An object as a picker offers it. */
|
||||
export interface ObjectListEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the console has already looked up for the account being viewed: the
|
||||
* names that stand in for ids in lists, and the options pickers offer.
|
||||
* Switching accounts clears it.
|
||||
*/
|
||||
interface CacheState {
|
||||
/** Object type → id → the name shown for it. */
|
||||
displayNames: Record<string, Record<string, string>>;
|
||||
|
||||
/** Picker options, by the view or object they list. */
|
||||
objectLists: Record<string, ObjectListEntry[]>;
|
||||
|
||||
setDisplayNames: (objectType: string, entries: Record<string, string>) => void;
|
||||
getDisplayName: (objectType: string, id: string) => string | undefined;
|
||||
invalidateCache: (objectType: string) => void;
|
||||
|
||||
setObjectList: (key: string, entries: ObjectListEntry[]) => void;
|
||||
getObjectList: (key: string) => ObjectListEntry[] | undefined;
|
||||
invalidateObjectList: (key: string) => void;
|
||||
invalidateAllObjectLists: () => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
@@ -31,53 +34,16 @@ export const useCacheStore = create<CacheState>()((set, get) => ({
|
||||
displayNames: {},
|
||||
objectLists: {},
|
||||
|
||||
setDisplayNames: (objectType, entries) => {
|
||||
set((state) => ({
|
||||
displayNames: {
|
||||
...state.displayNames,
|
||||
[objectType]: {
|
||||
...state.displayNames[objectType],
|
||||
...entries,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
setDisplayNames: (objectType, entries) =>
|
||||
set(({ displayNames }) => ({
|
||||
displayNames: { ...displayNames, [objectType]: { ...displayNames[objectType], ...entries } },
|
||||
})),
|
||||
|
||||
getDisplayName: (objectType, id) => {
|
||||
return get().displayNames[objectType]?.[id];
|
||||
},
|
||||
getDisplayName: (objectType, id) => get().displayNames[objectType]?.[id],
|
||||
|
||||
invalidateCache: (objectType) => {
|
||||
set((state) => {
|
||||
const { [objectType]: _removed, ...rest } = state.displayNames;
|
||||
void _removed;
|
||||
return { displayNames: rest };
|
||||
});
|
||||
},
|
||||
setObjectList: (key, entries) => set(({ objectLists }) => ({ objectLists: { ...objectLists, [key]: entries } })),
|
||||
|
||||
setObjectList: (key, entries) => {
|
||||
set((state) => ({
|
||||
objectLists: { ...state.objectLists, [key]: entries },
|
||||
}));
|
||||
},
|
||||
invalidateAllObjectLists: () => set({ objectLists: {} }),
|
||||
|
||||
getObjectList: (key) => {
|
||||
return get().objectLists[key];
|
||||
},
|
||||
|
||||
invalidateObjectList: (key) => {
|
||||
set((state) => {
|
||||
const { [key]: _removed, ...rest } = state.objectLists;
|
||||
void _removed;
|
||||
return { objectLists: rest };
|
||||
});
|
||||
},
|
||||
|
||||
invalidateAllObjectLists: () => {
|
||||
set({ objectLists: {} });
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ displayNames: {}, objectLists: {} });
|
||||
},
|
||||
clearAll: () => set({ displayNames: {}, objectLists: {} }),
|
||||
}));
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
// The served schema goes through the settings regrouping and extra columns first; here it passes as is.
|
||||
vi.mock('@/lib/settingsLayout', () => ({ withRegroupedSettings: (s: Schema) => s }));
|
||||
vi.mock('@/lib/listColumns', () => ({ withExtraColumns: (s: Schema) => s }));
|
||||
|
||||
const { useSchemaStore } = await import('./schemaStore');
|
||||
|
||||
const schema = {
|
||||
objects: {
|
||||
'x:Domain': { type: 'object', description: 'Mail domains', permissionPrefix: 'sysDomain' },
|
||||
'x:Smtp': { type: 'singleton', description: 'SMTP server', permissionPrefix: 'sysSmtp' },
|
||||
'x:Thing': { type: 'object', description: 'Things', permissionPrefix: 'sysThing' },
|
||||
'x:Quiet': { type: 'object', description: '', permissionPrefix: 'sysQuiet' },
|
||||
'x:SmtpView': { type: 'view', objectName: 'x:Smtp' },
|
||||
},
|
||||
forms: {
|
||||
'x:SmtpView': {
|
||||
sections: [
|
||||
{ title: 'Greeting', fields: [{ name: 'banner', label: 'Banner' }] },
|
||||
{ fields: [{ name: '@type', label: 'Kind' }] },
|
||||
],
|
||||
},
|
||||
'x:Smtp': { title: 'SMTP', sections: [] },
|
||||
},
|
||||
lists: { 'x:Domain': { singularName: 'domain' } },
|
||||
layouts: [
|
||||
{
|
||||
name: 'Mail',
|
||||
icon: 'mail',
|
||||
items: [
|
||||
{ link: { name: 'Domains', icon: 'globe', viewName: 'x:Domain' } },
|
||||
{
|
||||
container: {
|
||||
name: 'Delivery',
|
||||
icon: 'send',
|
||||
items: [
|
||||
{ type: 'link', name: 'SMTP', viewName: 'x:SmtpView' },
|
||||
{ type: 'container', name: 'More', items: [{ type: 'link', name: 'Things', viewName: 'x:Thing' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'Other', icon: 'x', items: [{ link: { name: 'Domains again', icon: 'globe', viewName: 'x:Domain' } }] },
|
||||
],
|
||||
} as unknown as Schema;
|
||||
|
||||
useSchemaStore.getState().setSchema(schema);
|
||||
const { viewToSection, searchIndex, isLoaded } = useSchemaStore.getState();
|
||||
|
||||
describe('schemaStore', () => {
|
||||
it('is loaded once a schema is set', () => {
|
||||
expect(isLoaded).toBe(true);
|
||||
expect(useSchemaStore.getState().schema).toBe(schema);
|
||||
});
|
||||
|
||||
it('puts each linked view in the first section that links it', () => {
|
||||
expect(viewToSection).toEqual({ 'x:Domain': 'Mail', 'x:SmtpView': 'Mail', 'x:Thing': 'Mail' });
|
||||
});
|
||||
|
||||
it('indexes every link with its path, top-level ones with their icon', () => {
|
||||
expect(searchIndex.filter((e) => e.type === 'link' && !e.objectType)).toEqual([
|
||||
{
|
||||
text: 'Domains',
|
||||
type: 'link',
|
||||
viewName: 'x:Domain',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > Domains',
|
||||
icon: 'globe',
|
||||
},
|
||||
{ text: 'SMTP', type: 'link', viewName: 'x:SmtpView', section: 'Mail', breadcrumb: 'Mail > Delivery > SMTP' },
|
||||
{
|
||||
text: 'Things',
|
||||
type: 'link',
|
||||
viewName: 'x:Thing',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > Delivery > More > Things',
|
||||
},
|
||||
{
|
||||
text: 'Domains again',
|
||||
type: 'link',
|
||||
viewName: 'x:Domain',
|
||||
section: 'Other',
|
||||
breadcrumb: 'Other > Domains again',
|
||||
icon: 'globe',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('indexes object descriptions under the name a reader knows them by', () => {
|
||||
expect(searchIndex.filter((e) => e.objectType)).toEqual([
|
||||
{
|
||||
text: 'Mail domains',
|
||||
type: 'link',
|
||||
viewName: 'x:Domain',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > Domain',
|
||||
objectType: 'object',
|
||||
},
|
||||
{
|
||||
text: 'SMTP server',
|
||||
type: 'link',
|
||||
viewName: 'x:Smtp',
|
||||
section: '',
|
||||
breadcrumb: 'SMTP',
|
||||
objectType: 'singleton',
|
||||
},
|
||||
{
|
||||
text: 'Things',
|
||||
type: 'link',
|
||||
viewName: 'x:Thing',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > Thing',
|
||||
objectType: 'object',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('indexes form sections and fields, with the field name as a keyword', () => {
|
||||
expect(searchIndex.filter((e) => e.type !== 'link')).toEqual([
|
||||
{
|
||||
text: 'Greeting',
|
||||
type: 'form',
|
||||
viewName: 'x:SmtpView',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > SmtpView > Greeting',
|
||||
},
|
||||
{
|
||||
text: 'Banner',
|
||||
type: 'field',
|
||||
viewName: 'x:SmtpView',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > SmtpView > Banner',
|
||||
keywords: ['banner'],
|
||||
},
|
||||
{
|
||||
text: 'Kind',
|
||||
type: 'field',
|
||||
viewName: 'x:SmtpView',
|
||||
section: 'Mail',
|
||||
breadcrumb: 'Mail > SmtpView > Kind',
|
||||
keywords: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists links first, then descriptions, then forms', () => {
|
||||
const kinds = searchIndex.map((e) => (e.objectType ? 'description' : e.type));
|
||||
expect(kinds).toEqual([
|
||||
'link',
|
||||
'link',
|
||||
'link',
|
||||
'link',
|
||||
'description',
|
||||
'description',
|
||||
'description',
|
||||
'form',
|
||||
'field',
|
||||
'field',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+78
-117
@@ -1,17 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { Schema, LayoutSubItem } from '@/types/schema';
|
||||
import { withRegroupedSettings } from '@/lib/settingsLayout';
|
||||
import { withExtraColumns } from '@/lib/listColumns';
|
||||
import { withRegroupedSettings } from '@/lib/settingsLayout';
|
||||
import type { LayoutSubItem, Schema } from '@/types/schema';
|
||||
|
||||
/** Something global search can find: a page, a form section or a field. */
|
||||
export interface SearchIndexEntry {
|
||||
text: string;
|
||||
type: 'link' | 'field' | 'form';
|
||||
@@ -26,140 +24,111 @@ export interface SearchIndexEntry {
|
||||
interface SchemaState {
|
||||
schema: Schema | null;
|
||||
isLoaded: boolean;
|
||||
/** The layout (top-level section) each view is linked from; the first one, when several link it. */
|
||||
viewToSection: Record<string, string>;
|
||||
searchIndex: SearchIndexEntry[];
|
||||
|
||||
setSchema: (schema: Schema) => void;
|
||||
}
|
||||
|
||||
function walkLayouts(schema: Schema): { viewToSection: Record<string, string>; linkEntries: SearchIndexEntry[] } {
|
||||
/** Every link in the layouts, with the path of names that leads to it. */
|
||||
function indexLinks(schema: Schema) {
|
||||
const viewToSection: Record<string, string> = {};
|
||||
const linkEntries: SearchIndexEntry[] = [];
|
||||
|
||||
function visit(items: LayoutSubItem[], sectionName: string, parentPath: string): void {
|
||||
for (const sub of items) {
|
||||
if (sub.type === 'link') {
|
||||
if (!(sub.viewName in viewToSection)) {
|
||||
viewToSection[sub.viewName] = sectionName;
|
||||
}
|
||||
linkEntries.push({
|
||||
text: sub.name,
|
||||
type: 'link',
|
||||
viewName: sub.viewName,
|
||||
section: sectionName,
|
||||
breadcrumb: `${parentPath} > ${sub.name}`,
|
||||
});
|
||||
} else if (sub.type === 'container') {
|
||||
visit(sub.items, sectionName, `${parentPath} > ${sub.name}`);
|
||||
}
|
||||
const links: SearchIndexEntry[] = [];
|
||||
const add = (section: string, path: string, name: string, viewName: string, icon?: string) => {
|
||||
viewToSection[viewName] ??= section;
|
||||
links.push({
|
||||
text: name,
|
||||
type: 'link',
|
||||
viewName,
|
||||
section,
|
||||
breadcrumb: `${path} > ${name}`,
|
||||
...(icon !== undefined && { icon }),
|
||||
});
|
||||
};
|
||||
const walk = (section: string, path: string, items: LayoutSubItem[]) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') add(section, path, item.name, item.viewName);
|
||||
else if (item.type === 'container') walk(section, `${path} > ${item.name}`, item.items);
|
||||
}
|
||||
};
|
||||
for (const { name: section, items } of schema.layouts) {
|
||||
for (const item of items) {
|
||||
if ('link' in item) add(section, section, item.link.name, item.link.viewName, item.link.icon);
|
||||
else if ('container' in item) walk(section, `${section} > ${item.container.name}`, item.container.items);
|
||||
}
|
||||
}
|
||||
|
||||
for (const layout of schema.layouts) {
|
||||
const sectionName = layout.name;
|
||||
for (const item of layout.items) {
|
||||
if ('link' in item) {
|
||||
if (!(item.link.viewName in viewToSection)) {
|
||||
viewToSection[item.link.viewName] = sectionName;
|
||||
}
|
||||
linkEntries.push({
|
||||
text: item.link.name,
|
||||
type: 'link',
|
||||
viewName: item.link.viewName,
|
||||
section: sectionName,
|
||||
breadcrumb: `${sectionName} > ${item.link.name}`,
|
||||
icon: item.link.icon,
|
||||
});
|
||||
} else if ('container' in item) {
|
||||
visit(item.container.items, sectionName, `${sectionName} > ${item.container.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { viewToSection, linkEntries };
|
||||
return { viewToSection, links };
|
||||
}
|
||||
|
||||
function buildSearchIndex(
|
||||
schema: Schema,
|
||||
viewToSection: Record<string, string>,
|
||||
linkEntries: SearchIndexEntry[],
|
||||
): SearchIndexEntry[] {
|
||||
const entries: SearchIndexEntry[] = [...linkEntries];
|
||||
|
||||
function displayNameFor(viewName: string): string {
|
||||
const obj = schema.objects[viewName];
|
||||
if (!obj) return viewName.replace(/^x:/, '');
|
||||
let resolvedName = viewName;
|
||||
let resolvedObj = obj;
|
||||
if (obj.type === 'view') {
|
||||
const parent = schema.objects[obj.objectName];
|
||||
if (parent && parent.type !== 'view') {
|
||||
resolvedName = obj.objectName;
|
||||
resolvedObj = parent;
|
||||
}
|
||||
}
|
||||
if (resolvedObj.type === 'singleton') {
|
||||
const form = schema.forms[viewName] ?? schema.forms[resolvedName];
|
||||
if (form?.title) return form.title;
|
||||
}
|
||||
if (resolvedObj.type === 'object') {
|
||||
const list = schema.lists[viewName] ?? schema.lists[resolvedName];
|
||||
if (list?.singularName) {
|
||||
return list.singularName.charAt(0).toUpperCase() + list.singularName.slice(1);
|
||||
}
|
||||
}
|
||||
return viewName.replace(/^x:/, '');
|
||||
/**
|
||||
* The name a view goes by in a breadcrumb: a singleton's form title, an
|
||||
* object's singular name, or else its type without `x:`. A view of another
|
||||
* object is named after that object, unless its own form or list says more.
|
||||
*/
|
||||
function viewTitle(schema: Schema, viewName: string): string {
|
||||
const fallback = viewName.replace(/^x:/, '');
|
||||
const entry = schema.objects[viewName];
|
||||
if (!entry) return fallback;
|
||||
const shown = entry.type === 'view' ? schema.objects[entry.objectName] : entry;
|
||||
const named = entry.type === 'view' && shown && shown.type !== 'view' ? entry.objectName : viewName;
|
||||
const kind = shown && shown.type !== 'view' ? shown.type : entry.type;
|
||||
if (kind === 'singleton') {
|
||||
const title = (schema.forms[viewName] ?? schema.forms[named])?.title;
|
||||
if (title) return title;
|
||||
} else if (kind === 'object') {
|
||||
const singular = (schema.lists[viewName] ?? schema.lists[named])?.singularName;
|
||||
if (singular) return singular[0].toUpperCase() + singular.slice(1);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
for (const [name, obj] of Object.entries(schema.objects)) {
|
||||
if (obj.type !== 'view' && obj.description) {
|
||||
const section = viewToSection[name] ?? '';
|
||||
const display = displayNameFor(name);
|
||||
entries.push({
|
||||
text: obj.description,
|
||||
type: 'link',
|
||||
viewName: name,
|
||||
section,
|
||||
breadcrumb: section ? `${section} > ${display}` : display,
|
||||
objectType: obj.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Links first, then each object's description, then every form section and field. */
|
||||
function buildIndex(schema: Schema) {
|
||||
const { viewToSection, links } = indexLinks(schema);
|
||||
const searchIndex = [...links];
|
||||
const under = (section: string, ...names: string[]) => [...(section ? [section] : []), ...names].join(' > ');
|
||||
|
||||
for (const [formKey, form] of Object.entries(schema.forms)) {
|
||||
const viewName = formKey;
|
||||
for (const [viewName, entry] of Object.entries(schema.objects)) {
|
||||
if (entry.type === 'view' || !entry.description) continue;
|
||||
const section = viewToSection[viewName] ?? '';
|
||||
const display = displayNameFor(viewName);
|
||||
searchIndex.push({
|
||||
text: entry.description,
|
||||
type: 'link',
|
||||
viewName,
|
||||
section,
|
||||
breadcrumb: under(section, viewTitle(schema, viewName)),
|
||||
objectType: entry.type,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [viewName, form] of Object.entries(schema.forms)) {
|
||||
const section = viewToSection[viewName] ?? '';
|
||||
const title = viewTitle(schema, viewName);
|
||||
for (const formSection of form.sections) {
|
||||
if (formSection.title) {
|
||||
entries.push({
|
||||
searchIndex.push({
|
||||
text: formSection.title,
|
||||
type: 'form',
|
||||
viewName,
|
||||
section,
|
||||
breadcrumb: section ? `${section} > ${display} > ${formSection.title}` : `${display} > ${formSection.title}`,
|
||||
breadcrumb: under(section, title, formSection.title),
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of formSection.fields) {
|
||||
const keywords: string[] = [];
|
||||
if (field.name && field.name !== '@type') {
|
||||
keywords.push(field.name);
|
||||
}
|
||||
entries.push({
|
||||
searchIndex.push({
|
||||
text: field.label,
|
||||
type: 'field',
|
||||
viewName,
|
||||
section,
|
||||
breadcrumb: section ? `${section} > ${display} > ${field.label}` : `${display} > ${field.label}`,
|
||||
keywords,
|
||||
breadcrumb: under(section, title, field.label),
|
||||
// Search by the property name too, except the variant switch.
|
||||
keywords: field.name && field.name !== '@type' ? [field.name] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
return { viewToSection, searchIndex };
|
||||
}
|
||||
|
||||
export const useSchemaStore = create<SchemaState>()((set) => ({
|
||||
@@ -169,16 +138,8 @@ export const useSchemaStore = create<SchemaState>()((set) => ({
|
||||
searchIndex: [],
|
||||
|
||||
setSchema: (served) => {
|
||||
// inbuxa: Settings grouped by task, not by config file (settings-reorg),
|
||||
// and columns the served lists leave out.
|
||||
// Settings grouped by task rather than by config file, plus the list columns the server leaves out.
|
||||
const schema = withExtraColumns(withRegroupedSettings(served));
|
||||
const { viewToSection, linkEntries } = walkLayouts(schema);
|
||||
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
|
||||
set({
|
||||
schema,
|
||||
isLoaded: true,
|
||||
viewToSection,
|
||||
searchIndex,
|
||||
});
|
||||
set({ schema, isLoaded: true, ...buildIndex(schema) });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const memory = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => memory.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void memory.set(k, v),
|
||||
removeItem: (k: string) => void memory.delete(k),
|
||||
clear: () => memory.clear(),
|
||||
});
|
||||
|
||||
const queueAccountTheme = vi.fn();
|
||||
const applyPalette = vi.fn();
|
||||
const applyA11yPrefs = vi.fn();
|
||||
vi.mock('@/lib/accountSettings', () => ({ queueAccountTheme: (...a: unknown[]) => queueAccountTheme(...a) }));
|
||||
vi.mock('@/lib/palettes', async (real) => ({
|
||||
...(await real<typeof import('@/lib/palettes')>()),
|
||||
applyPalette: (...a: unknown[]) => applyPalette(...a),
|
||||
}));
|
||||
vi.mock('@/lib/a11yPrefs', async (real) => ({
|
||||
...(await real<typeof import('@/lib/a11yPrefs')>()),
|
||||
applyA11yPrefs: (...a: unknown[]) => applyA11yPrefs(...a),
|
||||
}));
|
||||
|
||||
const { useUIStore, isAdminLayout, DEFAULT_ADMIN_LAYOUT } = await import('./uiStore');
|
||||
const ui = () => useUIStore.getState();
|
||||
const isDark = () => document.documentElement.classList.contains('dark');
|
||||
|
||||
function prefersDark(dark: boolean) {
|
||||
window.matchMedia = ((query: string) => ({ matches: dark && query.includes('dark') })) as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUIStore.setState({ theme: 'light', palette: 'ihasmail', contrast: 'system', motion: 'system' });
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
describe('theme', () => {
|
||||
it('sets the dark class and queues the theme for the account', () => {
|
||||
ui().setTheme('dark');
|
||||
expect(isDark()).toBe(true);
|
||||
expect(queueAccountTheme).toHaveBeenLastCalledWith('ihasmail', 'dark', 'dark');
|
||||
ui().toggleTheme();
|
||||
expect(ui().theme).toBe('light');
|
||||
expect(isDark()).toBe(false);
|
||||
expect(queueAccountTheme).toHaveBeenLastCalledWith('ihasmail', 'light', 'light');
|
||||
});
|
||||
|
||||
it('applies a palette and queues it without a mode', () => {
|
||||
ui().setTheme('dark');
|
||||
ui().setPalette('dracula');
|
||||
expect(applyPalette).toHaveBeenCalledWith('dracula');
|
||||
expect(ui().palette).toBe('dracula');
|
||||
expect(queueAccountTheme).toHaveBeenLastCalledWith('dracula', null, 'dark');
|
||||
});
|
||||
|
||||
it("takes the account's theme without writing it back", () => {
|
||||
ui().applyAccountTheme('dracula', 'dark');
|
||||
expect(ui()).toMatchObject({ palette: 'dracula', theme: 'dark' });
|
||||
expect(isDark()).toBe(true);
|
||||
expect(queueAccountTheme).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows the system for a "system" account theme', () => {
|
||||
prefersDark(true);
|
||||
ui().applyAccountTheme(null, 'system');
|
||||
expect(ui().theme).toBe('dark');
|
||||
prefersDark(false);
|
||||
ui().applyAccountTheme(null, 'system');
|
||||
expect(ui().theme).toBe('light');
|
||||
});
|
||||
|
||||
it('leaves what the account has no setting for', () => {
|
||||
ui().setTheme('dark');
|
||||
ui().applyAccountTheme(null, null);
|
||||
expect(ui()).toMatchObject({ palette: 'ihasmail', theme: 'dark' });
|
||||
expect(applyPalette).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('applies contrast and motion together', () => {
|
||||
ui().setContrast('high');
|
||||
expect(applyA11yPrefs).toHaveBeenLastCalledWith('high', 'system');
|
||||
ui().setMotion('reduce');
|
||||
expect(applyA11yPrefs).toHaveBeenLastCalledWith('high', 'reduce');
|
||||
expect(ui()).toMatchObject({ contrast: 'high', motion: 'reduce' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('opens, closes and folds the sidebar', () => {
|
||||
ui().setSidebarOpen(false);
|
||||
ui().toggleSidebar();
|
||||
expect(ui().sidebarOpen).toBe(true);
|
||||
const folded = ui().sidebarCollapsed;
|
||||
ui().toggleSidebarCollapsed();
|
||||
expect(ui().sidebarCollapsed).toBe(!folded);
|
||||
});
|
||||
|
||||
it('keeps the layout and the active section', () => {
|
||||
ui().setAdminLayout('legacy');
|
||||
ui().setActiveSection('mail');
|
||||
expect(ui()).toMatchObject({ adminLayout: 'legacy', activeSection: 'mail' });
|
||||
});
|
||||
|
||||
it('knows the two layouts', () => {
|
||||
expect(DEFAULT_ADMIN_LAYOUT).toBe('modern');
|
||||
expect(isAdminLayout('modern')).toBe(true);
|
||||
expect(isAdminLayout('legacy')).toBe(true);
|
||||
expect(isAdminLayout('sidebar')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('keeps the look and the layout in local storage, not the open sidebar or section', () => {
|
||||
ui().setTheme('dark');
|
||||
ui().setActiveSection('mail');
|
||||
ui().setSidebarOpen(true);
|
||||
const saved = JSON.parse(memory.get('inbuxa-ui')!);
|
||||
expect(Object.keys(saved.state).sort()).toEqual(
|
||||
['adminLayout', 'contrast', 'motion', 'palette', 'sidebarCollapsed', 'theme'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads them back, replacing values it does not know and applying the look', async () => {
|
||||
memory.set(
|
||||
'inbuxa-ui',
|
||||
JSON.stringify({
|
||||
state: { theme: 'dark', palette: 'nope', adminLayout: 'tiles', contrast: 'max', motion: 'reduce' },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
await useUIStore.persist.rehydrate();
|
||||
expect(ui()).toMatchObject({ theme: 'dark', adminLayout: 'modern', contrast: 'system', motion: 'reduce' });
|
||||
expect(isDark()).toBe(true);
|
||||
expect(applyPalette).toHaveBeenLastCalledWith('ihasmail');
|
||||
expect(applyA11yPrefs).toHaveBeenLastCalledWith('system', 'reduce');
|
||||
});
|
||||
});
|
||||
+76
-108
@@ -1,25 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { queueAccountTheme } from '@/lib/accountSettings';
|
||||
import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes';
|
||||
import { applyA11yPrefs, isContrastPref, isMotionPref, type ContrastPref, type MotionPref } from '@/lib/a11yPrefs';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { queueAccountTheme } from '@/lib/accountSettings';
|
||||
import { applyA11yPrefs, isContrastPref, isMotionPref, type ContrastPref, type MotionPref } from '@/lib/a11yPrefs';
|
||||
import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* INBUXA: which shell draws the navigation. "modern" is the two-tier shell —
|
||||
* the layout switcher in the top bar over a section bar carrying that layout's
|
||||
* own items, and no sidebar. "legacy" is the sidebar the old web UI had. A
|
||||
* layout too deep for a menu bar keeps the sidebar under either setting.
|
||||
* Which shell draws the navigation. "modern" puts the layout switcher in the
|
||||
* top bar over a section bar with that layout's items; "legacy" is the
|
||||
* sidebar. A layout too deep for a menu bar keeps the sidebar either way.
|
||||
*/
|
||||
export type AdminLayout = 'modern' | 'legacy';
|
||||
|
||||
@@ -31,15 +27,14 @@ export function isAdminLayout(value: unknown): value is AdminLayout {
|
||||
|
||||
interface UIState {
|
||||
theme: Theme;
|
||||
/** INBUXA: the color palette, the same set INBUXA webmail offers. */
|
||||
/** The color palette, the same set inbuxa webmail offers. */
|
||||
palette: PaletteId;
|
||||
/** INBUXA: the navigation shell, the reader's own choice. */
|
||||
adminLayout: AdminLayout;
|
||||
/** inbuxa: high contrast and reduced motion, or the system's setting (roadmap item 13). */
|
||||
/** High contrast and reduced motion, or whatever the system asks for. */
|
||||
contrast: ContrastPref;
|
||||
motion: MotionPref;
|
||||
sidebarOpen: boolean;
|
||||
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
|
||||
/** The sidebar folded to a rail of icons, on wide screens. */
|
||||
sidebarCollapsed: boolean;
|
||||
activeSection: string;
|
||||
|
||||
@@ -49,7 +44,7 @@ interface UIState {
|
||||
setAdminLayout: (layout: AdminLayout) => void;
|
||||
setContrast: (contrast: ContrastPref) => void;
|
||||
setMotion: (motion: MotionPref) => void;
|
||||
/** INBUXA: take the theme stored with the account, without writing it back. */
|
||||
/** Take the theme saved with the account, without saving it back. */
|
||||
applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
@@ -57,112 +52,85 @@ interface UIState {
|
||||
setActiveSection: (section: string) => void;
|
||||
}
|
||||
|
||||
function applyThemeClass(theme: Theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
const matches = (query: string) => typeof window !== 'undefined' && window.matchMedia?.(query).matches;
|
||||
|
||||
const systemTheme = (): Theme => (matches('(prefers-color-scheme: dark)') ? 'dark' : 'light');
|
||||
|
||||
const showTheme = (theme: Theme) => document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme:
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||
sidebarCollapsed: false,
|
||||
palette: DEFAULT_PALETTE,
|
||||
adminLayout: DEFAULT_ADMIN_LAYOUT,
|
||||
contrast: 'system',
|
||||
motion: 'system',
|
||||
activeSection: '',
|
||||
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'light' ? 'dark' : 'light';
|
||||
applyThemeClass(next);
|
||||
set({ theme: next });
|
||||
queueAccountTheme(get().palette, next, next);
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
applyThemeClass(theme);
|
||||
(set, get) => {
|
||||
/** Show a theme the reader picked, and save it with their account. */
|
||||
const pickTheme = (theme: Theme) => {
|
||||
showTheme(theme);
|
||||
set({ theme });
|
||||
queueAccountTheme(get().palette, theme, theme);
|
||||
},
|
||||
};
|
||||
|
||||
toggleSidebar: () => {
|
||||
set({ sidebarOpen: !get().sidebarOpen });
|
||||
},
|
||||
return {
|
||||
theme: systemTheme(),
|
||||
// Open on tablets and up; a phone opens it from the menu button.
|
||||
sidebarOpen: typeof window === 'undefined' || (window.matchMedia?.('(min-width: 768px)').matches ?? true),
|
||||
sidebarCollapsed: false,
|
||||
palette: DEFAULT_PALETTE,
|
||||
adminLayout: DEFAULT_ADMIN_LAYOUT,
|
||||
contrast: 'system',
|
||||
motion: 'system',
|
||||
activeSection: '',
|
||||
|
||||
setSidebarOpen: (open) => {
|
||||
set({ sidebarOpen: open });
|
||||
},
|
||||
setTheme: pickTheme,
|
||||
toggleTheme: () => pickTheme(get().theme === 'light' ? 'dark' : 'light'),
|
||||
|
||||
setPalette: (palette) => {
|
||||
applyPalette(palette);
|
||||
set({ palette });
|
||||
queueAccountTheme(palette, null, get().theme);
|
||||
},
|
||||
|
||||
setAdminLayout: (layout) => {
|
||||
set({ adminLayout: layout });
|
||||
},
|
||||
|
||||
setContrast: (contrast) => {
|
||||
applyA11yPrefs(contrast, get().motion);
|
||||
set({ contrast });
|
||||
},
|
||||
|
||||
setMotion: (motion) => {
|
||||
applyA11yPrefs(get().contrast, motion);
|
||||
set({ motion });
|
||||
},
|
||||
|
||||
applyAccountTheme: (palette, mode) => {
|
||||
const next: Partial<UIState> = {};
|
||||
if (palette) {
|
||||
setPalette: (palette) => {
|
||||
applyPalette(palette);
|
||||
next.palette = palette;
|
||||
}
|
||||
if (mode) {
|
||||
const theme: Theme =
|
||||
mode === 'system' ? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : mode;
|
||||
applyThemeClass(theme);
|
||||
next.theme = theme;
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
set({ palette });
|
||||
queueAccountTheme(palette, null, get().theme);
|
||||
},
|
||||
|
||||
toggleSidebarCollapsed: () => {
|
||||
set({ sidebarCollapsed: !get().sidebarCollapsed });
|
||||
},
|
||||
applyAccountTheme: (palette, mode) => {
|
||||
const theme = mode && (mode === 'system' ? systemTheme() : mode);
|
||||
if (palette) applyPalette(palette);
|
||||
if (theme) showTheme(theme);
|
||||
set({ ...(palette && { palette }), ...(theme && { theme }) });
|
||||
},
|
||||
|
||||
setActiveSection: (section) => {
|
||||
set({ activeSection: section });
|
||||
},
|
||||
}),
|
||||
setContrast: (contrast) => {
|
||||
applyA11yPrefs(contrast, get().motion);
|
||||
set({ contrast });
|
||||
},
|
||||
|
||||
setMotion: (motion) => {
|
||||
applyA11yPrefs(get().contrast, motion);
|
||||
set({ motion });
|
||||
},
|
||||
|
||||
setAdminLayout: (adminLayout) => set({ adminLayout }),
|
||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
|
||||
toggleSidebarCollapsed: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
setActiveSection: (activeSection) => set({ activeSection }),
|
||||
};
|
||||
},
|
||||
{
|
||||
name: 'inbuxa-ui',
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
palette: state.palette,
|
||||
adminLayout: state.adminLayout,
|
||||
contrast: state.contrast,
|
||||
motion: state.motion,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
partialize: ({ theme, palette, adminLayout, contrast, motion, sidebarCollapsed }) => ({
|
||||
theme,
|
||||
palette,
|
||||
adminLayout,
|
||||
contrast,
|
||||
motion,
|
||||
sidebarCollapsed,
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
applyThemeClass(state.theme);
|
||||
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
|
||||
if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT;
|
||||
if (!isContrastPref(state.contrast)) state.contrast = 'system';
|
||||
if (!isMotionPref(state.motion)) state.motion = 'system';
|
||||
applyA11yPrefs(state.contrast, state.motion);
|
||||
}
|
||||
};
|
||||
// Saved values from another version may no longer exist; fall back, then show the look.
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT;
|
||||
if (!isContrastPref(state.contrast)) state.contrast = 'system';
|
||||
if (!isMotionPref(state.motion)) state.motion = 'system';
|
||||
showTheme(state.theme);
|
||||
applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
|
||||
applyA11yPrefs(state.contrast, state.motion);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
+14
-18
@@ -1,33 +1,22 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface JmapRequest {
|
||||
using: string[];
|
||||
methodCalls: JmapMethodCall[];
|
||||
}
|
||||
/** Shapes from JMAP core (RFC 8620), and the errors the server adds to them. */
|
||||
|
||||
/** One method call or response: name, arguments, call id. */
|
||||
export type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
export type JmapMethodResponse = [string, Record<string, unknown>, string];
|
||||
|
||||
/** The body of a response (RFC 8620 §3.4). */
|
||||
export interface JmapResponse {
|
||||
methodResponses: JmapMethodResponse[];
|
||||
sessionState?: string;
|
||||
}
|
||||
|
||||
export type JmapMethodResponse = [string, Record<string, unknown>, string];
|
||||
|
||||
export interface JmapGetResponse {
|
||||
accountId: string;
|
||||
state: string;
|
||||
list: Record<string, unknown>[];
|
||||
notFound: string[];
|
||||
}
|
||||
|
||||
/** `<type>/query` (§5.5). The server leaves `limit` out once it has sent everything. */
|
||||
export interface JmapQueryResponse {
|
||||
accountId: string;
|
||||
queryState: string;
|
||||
@@ -38,34 +27,41 @@ export interface JmapQueryResponse {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** `<type>/set` (§5.3): what happened to each create, update and destroy. */
|
||||
export interface JmapSetResponse {
|
||||
accountId: string;
|
||||
oldState: string;
|
||||
newState: string;
|
||||
created: Record<string, Record<string, unknown>> | null;
|
||||
notCreated: Record<string, JmapSetError> | null;
|
||||
/** Null for an update that changed nothing the server computes. */
|
||||
updated: Record<string, null | Record<string, unknown>> | null;
|
||||
notUpdated: Record<string, JmapSetError> | null;
|
||||
destroyed: string[] | null;
|
||||
notDestroyed: Record<string, JmapSetError> | null;
|
||||
}
|
||||
|
||||
/** Why one create, update or destroy failed. */
|
||||
export interface JmapSetError {
|
||||
type: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
/** For `alreadyExists`: the object that's already there. */
|
||||
existingId?: string;
|
||||
/** The object the error is about; a failed reload names the settings object that didn't build. */
|
||||
objectId?: string | JmapObjectRef;
|
||||
/** Objects tied to this one, when that is why it failed. */
|
||||
linkedObjects?: string[];
|
||||
validationErrors?: ValidationError[];
|
||||
}
|
||||
|
||||
/** A registry object as the server names it in an error: its type without `x:`, and its id. */
|
||||
/** A registry object as an error names it: its type without `x:`, and its id. */
|
||||
export interface JmapObjectRef {
|
||||
object: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A property that failed the server's checks, and the bound it broke. */
|
||||
export interface ValidationError {
|
||||
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
|
||||
property: string;
|
||||
|
||||
Reference in new issue
Block a user