diff --git a/src/hooks/usePermissions.test.ts b/src/hooks/usePermissions.test.ts new file mode 100644 index 0000000..8546133 --- /dev/null +++ b/src/hooks/usePermissions.test.ts @@ -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]); + }); +}); diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 3c6d92b..33aae54 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,67 +1,39 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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) }; } diff --git a/src/services/jmap/client.test.ts b/src/services/jmap/client.test.ts new file mode 100644 index 0000000..dc416df --- /dev/null +++ b/src/services/jmap/client.test.ts @@ -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(); +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][] = [['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); + }); +}); diff --git a/src/services/jmap/client.ts b/src/services/jmap/client.ts index 5cd24a2..091cca8 100644 --- a/src/services/jmap/client.ts +++ b/src/services/jmap/client.ts @@ -1,20 +1,24 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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 { - 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 { - 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( +/** `/get` for some ids, or all of them (null). */ +export function jmapGet( objectType: string, accountId: string, ids: string[] | null, properties?: string[], signal?: AbortSignal, ): Promise { - const args: Record = { 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 { - const args: Record = { accountId, ...options }; - return jmapRequest([[`${objectType}/query`, args, '0']]); -} - interface JmapSetOptions { create?: Record>; update?: Record>; destroy?: string[]; } -export async function jmapSet( - objectType: string, - accountId: string, - options: JmapSetOptions = {}, -): Promise { - const args: Record = { accountId, ...options }; - return jmapRequest([[`${objectType}/set`, args, '0']]); +/** `/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 `/query` and the objects it found, in one request. */ +export function jmapQueryAndGet( objectType: string, accountId: string, queryOptions: JmapQueryOptions = {}, properties?: string[], ): Promise { - const queryArgs: Record = { - accountId, - ...queryOptions, - }; - - const getArgs: Record = { - 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 { + const [name, args] = responses[0]; + if (name === 'error') throw new Error(`JMAP ${call} error: ${(args as Record).type ?? 'unknown'}`); + return args as Record; +} + +/** 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[]> { - if (ids.length === 0) return []; - - const batchSize = useAuthStore.getState().maxObjectsInGet; - const allItems: Record[] = []; - - for (let offset = 0; offset < ids.length; offset += batchSize) { - if (signal?.aborted) break; - const batchIds = ids.slice(offset, offset + batchSize); - const args: Record = { 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).type ?? 'unknown'}`); - } - const list = (result[1] as { list?: Record[] }).list ?? []; - allItems.push(...list); + const perRequest = useAuthStore.getState().maxObjectsInGet; + const found: Record[] = []; + 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[] }; + 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 { - 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 = { 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).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[] }> { 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> { - const response = await apiFetch('/jmap/session'); - return response.json() as Promise>; -} +const getJson = async (path: string): Promise => (await apiFetch(path)).json() as Promise; -export async function fetchSchema(): Promise { - const response = await apiFetch('/api/schema'); - return response.json() as Promise; -} +/** The JMAP session: accounts, capabilities, the API URL. */ +export const fetchSession = () => getJson>('/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('/api/schema'); -export async function fetchAccountInfo(): Promise { - const response = await apiFetch('/api/account'); - return response.json() as Promise; -} +/** 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'); diff --git a/src/stores/accountStore.test.ts b/src/stores/accountStore.test.ts index ffe685b..756675b 100644 --- a/src/stores/accountStore.test.ts +++ b/src/stores/accountStore.test.ts @@ -1,115 +1,34 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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); }); }); diff --git a/src/stores/accountStore.ts b/src/stores/accountStore.ts index f7e8414..8ab04d6 100644 --- a/src/stores/accountStore.ts +++ b/src/stores/accountStore.ts @@ -1,16 +1,14 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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()((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), })); diff --git a/src/stores/authStore.test.ts b/src/stores/authStore.test.ts index 037b9a7..1fcbc75 100644 --- a/src/stores/authStore.test.ts +++ b/src/stores/authStore.test.ts @@ -1,234 +1,166 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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(); +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' }); + }); +}); diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index e581bd2..cba2574 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -1,35 +1,42 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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; + /** 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()( 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()( 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, }, ), diff --git a/src/stores/cacheStore.test.ts b/src/stores/cacheStore.test.ts index 88a4e34..3f702ef 100644 --- a/src/stores/cacheStore.test.ts +++ b/src/stores/cacheStore.test.ts @@ -1,94 +1,45 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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: {} }); }); }); diff --git a/src/stores/cacheStore.ts b/src/stores/cacheStore.ts index f70afad..09c70df 100644 --- a/src/stores/cacheStore.ts +++ b/src/stores/cacheStore.ts @@ -1,28 +1,31 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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>; - + /** Picker options, by the view or object they list. */ objectLists: Record; setDisplayNames: (objectType: string, entries: Record) => 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()((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: {} }), })); diff --git a/src/stores/schemaStore.test.ts b/src/stores/schemaStore.test.ts new file mode 100644 index 0000000..97dca95 --- /dev/null +++ b/src/stores/schemaStore.test.ts @@ -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', + ]); + }); +}); diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts index 6aa140f..9ee88ae 100644 --- a/src/stores/schemaStore.ts +++ b/src/stores/schemaStore.ts @@ -1,17 +1,15 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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; searchIndex: SearchIndexEntry[]; setSchema: (schema: Schema) => void; } -function walkLayouts(schema: Schema): { viewToSection: Record; linkEntries: SearchIndexEntry[] } { +/** Every link in the layouts, with the path of names that leads to it. */ +function indexLinks(schema: Schema) { const viewToSection: Record = {}; - 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, - 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()((set) => ({ @@ -169,16 +138,8 @@ export const useSchemaStore = create()((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) }); }, })); diff --git a/src/stores/uiStore.test.ts b/src/stores/uiStore.test.ts new file mode 100644 index 0000000..4540a31 --- /dev/null +++ b/src/stores/uiStore.test.ts @@ -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(); +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()), + applyPalette: (...a: unknown[]) => applyPalette(...a), +})); +vi.mock('@/lib/a11yPrefs', async (real) => ({ + ...(await real()), + 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'); + }); +}); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index e9e3844..80b5a3e 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -1,25 +1,21 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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()( 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 = {}; - 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); }, }, ), diff --git a/src/types/jmap.ts b/src/types/jmap.ts index a603bd3..a0cc811 100644 --- a/src/types/jmap.ts +++ b/src/types/jmap.ts @@ -1,33 +1,22 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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]; +export type JmapMethodResponse = [string, Record, string]; +/** The body of a response (RFC 8620 §3.4). */ export interface JmapResponse { methodResponses: JmapMethodResponse[]; sessionState?: string; } -export type JmapMethodResponse = [string, Record, string]; - -export interface JmapGetResponse { - accountId: string; - state: string; - list: Record[]; - notFound: string[]; -} - +/** `/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; } +/** `/set` (§5.3): what happened to each create, update and destroy. */ export interface JmapSetResponse { accountId: string; oldState: string; newState: string; created: Record> | null; notCreated: Record | null; + /** Null for an update that changed nothing the server computes. */ updated: Record> | null; notUpdated: Record | null; destroyed: string[] | null; notDestroyed: Record | 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;