Rewrite the JMAP client, the stores and the JMAP types #125

Merged
jcoffey-dev merged 1 commits from phase3/data into main 2026-10-19 02:10:51 +00:00
15 changed files with 1176 additions and 998 deletions

No files matched your search

+57
View File
@@ -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
View File
@@ -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) };
}
+221
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
});
+8 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
),
+32 -81
View File
@@ -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
View File
@@ -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: {} }),
}));
+170
View File
@@ -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
View File
@@ -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) });
},
}));
+146
View File
@@ -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
View File
@@ -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
View File
@@ -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;