diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 55993ad..33432c5 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -61,7 +61,12 @@ const DataInventoryPage = lazyFeature( () => import('@/features/compliance/DataInventoryPage'), (m) => m.DataInventoryPage, ); -// inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec). +// inbuxa: Settings › Overview (settings-reorg spec). +const SettingsOverviewPage = lazyFeature( + () => import('@/features/settings/SettingsOverviewPage'), + (m) => m.SettingsOverviewPage, +); +// inbuxa: Settings › Spam filter › Local AI (ai-spam-classification spec). const LocalAiPage = lazyFeature( () => import('@/features/ai/LocalAiPage'), (m) => m.LocalAiPage, @@ -149,6 +154,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LegacyProtocols') { return ; } + // inbuxa: Settings › Overview (settings-reorg). + if (componentName === 'SettingsOverview') { + return ; + } if (componentName === 'LocalAi') { return ; } diff --git a/src/components/layout/SectionNav.tsx b/src/components/layout/SectionNav.tsx index 2ce02ed..1a00bec 100644 --- a/src/components/layout/SectionNav.tsx +++ b/src/components/layout/SectionNav.tsx @@ -47,6 +47,9 @@ import { } from '@/lib/navTree'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; +/** Past this many links a menu is drawn in two columns. */ +const WIDE_MENU_LINKS = 12; + /** Room kept for the "More" trigger when not everything fits. */ const MORE_WIDTH = 92; @@ -110,14 +113,20 @@ function MenuBody({ items, sectionName, currentPath, edition, onPick }: MenuBody if (!subtreeHasVisibleLink(sub.items, edition)) return null; const links = visibleLinks(sub.items, edition); return ( - - + + {sub.name} {links.map((l) => ( onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')} @@ -163,6 +172,8 @@ function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measu const { name, items } = item.container; const containsActive = subtreeContainsActive(items, currentPath, sectionName); + // inbuxa: a long menu (Settings › Mail flow) splits into two columns rather than running off the screen. + const wide = visibleLinks(items, edition).length > WIDE_MENU_LINKS; return ( @@ -175,8 +186,26 @@ function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measu - - + + {wide ? ( +
+ +
+ ) : ( + + )}
); diff --git a/src/features/settings/SettingsOverviewPage.tsx b/src/features/settings/SettingsOverviewPage.tsx new file mode 100644 index 0000000..a2bea4d --- /dev/null +++ b/src/features/settings/SettingsOverviewPage.tsx @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Settings › Overview, the landing page for Settings (settings-reorg). + * A card per category explaining what that menu item covers, the guided setups, and, only + * when there are any, the pages whose values differ from the defaults. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Check, ListChecks } from 'lucide-react'; +import { IconTile } from '@/components/common/IconTile'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { useAccountStore } from '@/stores/accountStore'; +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { checkLinkVisible, resolveViewPath, topItemVisible } from '@/lib/navTree'; +import { SETTINGS_CATEGORIES, SETTINGS_LAYOUT_NAME } from '@/lib/settingsLayout'; +import { resolveObject } from '@/lib/schemaResolver'; +import { countChanged, singletonPages, type SettingsPageRef } from './changedFromDefault'; + +/** Calls per JMAP request; the protocol's own floor, so any server takes it. */ +const CALLS_PER_REQUEST = 16; + +interface GuidedSetup { + id: string; + title: string; + blurb: string; + /** Where the job starts. */ + to: string; + /** The view whose visibility decides whether this reader sees the card. */ + gate: string; + /** Whether it is already set up; left out when there's no cheap way to tell. */ + isDone?: () => Promise; +} + +async function anyExist(objectName: string): Promise { + const [res] = await jmapRequest([ + [`${objectName}/get`, { accountId: getAccountId(objectName), ids: null, properties: ['id'] }, '0'], + ]); + const list = res?.[1]?.list as unknown[] | undefined; + return (list?.length ?? 0) > 0; +} + +/** Each guided setup registers here as it lands. */ +const GUIDED_SETUPS: GuidedSetup[] = [ + { + id: 'dns', + title: 'Publish DNS records automatically', + blurb: 'Connect your DNS provider once, and each domain’s mail records are written and kept up to date for you.', + to: '/Management/x:Domain', + gate: 'x:DnsServer', + isDone: () => anyExist('x:DnsServer'), + }, + { + id: 'local-ai', + title: 'Local AI spam filtering', + blurb: 'Run a small model on this server to help sort spam, with nothing sent anywhere else.', + to: resolveViewPath(SETTINGS_LAYOUT_NAME, 'CustomComponent/LocalAi'), + gate: 'CustomComponent/LocalAi', + }, +]; + +function useChangedPages(pages: SettingsPageRef[]) { + const schema = useSchemaStore((s) => s.schema); + const [changed, setChanged] = useState<(SettingsPageRef & { count: number })[]>([]); + + useEffect(() => { + if (!schema || pages.length === 0) return; + const ctrl = new AbortController(); + const objects = [...new Set(pages.map((pg) => resolveObject(schema, pg.viewName)!.objectName))]; + + (async () => { + const values = new Map>(); + const batches: string[][] = []; + for (let i = 0; i < objects.length; i += CALLS_PER_REQUEST) batches.push(objects.slice(i, i + CALLS_PER_REQUEST)); + await Promise.all( + batches.map(async (batch) => { + const responses = await jmapRequest( + batch.map((obj, i) => [`${obj}/get`, { accountId: getAccountId(obj), ids: ['singleton'] }, String(i)]), + ctrl.signal, + ); + for (const [method, result, callId] of responses) { + if (method === 'error') continue; + const item = (result.list as Record[] | undefined)?.[0]; + if (item) values.set(batch[Number(callId)], item); + } + }), + ); + if (ctrl.signal.aborted) return; + const found = []; + for (const pg of pages) { + const data = values.get(resolveObject(schema, pg.viewName)!.objectName); + const count = data ? countChanged(schema, pg.viewName, data) : 0; + if (count > 0) found.push({ ...pg, count }); + } + setChanged(found); + })().catch(() => { + // The list is a convenience; a failed read just leaves it out. + }); + return () => ctrl.abort(); + }, [schema, pages]); + + return changed; +} + +function SetupCard({ setup }: { setup: GuidedSetup }) { + const { t } = useTranslation(); + const [done, setDone] = useState(false); + useEffect(() => { + let live = true; + setup + .isDone?.() + .then((d) => live && setDone(d)) + .catch(() => undefined); + return () => { + live = false; + }; + }, [setup]); + + return ( + +
+ {setup.title} + {done && ( + + + {t('settingsOverview.setUp', 'Set up')} + + )} +
+

{setup.blurb}

+ + ); +} + +export function SettingsOverviewPage() { + const { t } = useTranslation(); + const schema = useSchemaStore((s) => s.schema); + const edition = useAccountStore((s) => s.edition); + + const layout = useMemo(() => schema?.layouts.find((l) => l.name === SETTINGS_LAYOUT_NAME), [schema]); + + // What each item in the menu bar covers: explainers only, the bar does the navigating. + const cards = useMemo(() => { + if (!layout) return []; + return SETTINGS_CATEGORIES.filter((cat) => { + const item = layout.items.find((it) => 'container' in it && it.container.name === cat.name); + return item !== undefined && topItemVisible(item, edition); + }); + }, [layout, edition]); + + const pages = useMemo( + () => (schema && layout ? singletonPages(schema, layout.items).filter((pg) => checkLinkVisible(pg.viewName)) : []), + [schema, layout], + ); + const changed = useChangedPages(pages); + const setups = GUIDED_SETUPS.filter((s) => checkLinkVisible(s.gate)); + + return ( +
+
+

{t('settingsOverview.title', 'Settings')}

+

+ {t( + 'settingsOverview.subtitle', + 'The menu above groups everything the server does by job. Here is what each part covers. Anything you change is marked against its default.', + )} +

+
+ + {setups.length > 0 && ( +
+

+ + {t('settingsOverview.guided', 'Guided setup')} +

+
+ {setups.map((s) => ( + + ))} +
+
+ )} + +
+ {cards.map((cat) => ( +
+
+ +

{cat.name}

+
+

{cat.blurb}

+
+ ))} +
+ + {changed.length > 0 && ( +
+

+ {t('settingsOverview.changed', 'Changed from default')} +

+
    + {changed.map((pg) => ( +
  • + + {pg.label} + {pg.where} + + {t('settingsOverview.changedCount', '{{count}} changed', { count: pg.count })} + + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/features/settings/changedFromDefault.test.ts b/src/features/settings/changedFromDefault.test.ts new file mode 100644 index 0000000..033bd69 --- /dev/null +++ b/src/features/settings/changedFromDefault.test.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { countChanged, singletonPages } from './changedFromDefault'; +import type { Schema } from '@/types/schema'; + +const field = (type: Record) => ({ description: '', type, update: 'mutable' }); + +const schema = { + objects: { + 'x:Imap': { type: 'singleton', permissionPrefix: 'sysImap' }, + 'x:Email': { type: 'singleton', permissionPrefix: 'sysEmail' }, + 'x:Email/EmailLimits': { type: 'view', objectName: 'x:Email' }, + 'x:Tracer': { type: 'object', permissionPrefix: 'sysTracer' }, + }, + schemas: { + 'x:Imap': { type: 'single', schemaName: 'x:Imap' }, + 'x:Email': { type: 'single', schemaName: 'x:Email' }, + }, + fields: { + 'x:Imap': { + properties: { + allowPlainText: field({ type: 'boolean' }), + timeout: field({ type: 'number', format: 'duration' }), + banner: field({ type: 'string' }), + }, + defaults: { allowPlainText: false, timeout: 1800000 }, + }, + 'x:Email': { + properties: { maxSize: field({ type: 'number', format: 'size' }), defaultFolder: field({ type: 'string' }) }, + defaults: { maxSize: 1000, defaultFolder: 'Inbox' }, + }, + }, + forms: { + 'x:Imap': { sections: [{ fields: [{ name: 'allowPlainText' }, { name: 'timeout' }, { name: 'banner' }] }] }, + 'x:Email/EmailLimits': { sections: [{ fields: [{ name: 'maxSize' }] }] }, + }, + enums: {}, + lists: {}, + layouts: [], +} as unknown as Schema; + +describe('countChanged', () => { + it('counts fields that differ from a default it can describe', () => { + expect(countChanged(schema, 'x:Imap', { allowPlainText: true, timeout: 1800000, banner: 'hi' })).toBe(1); + }); + + it('treats an unset value as the default', () => { + expect(countChanged(schema, 'x:Imap', {})).toBe(0); + }); + + it('only counts the fields on the view it was asked about', () => { + // defaultFolder differs too, but it is not on the Limits page. + expect(countChanged(schema, 'x:Email/EmailLimits', { maxSize: 5, defaultFolder: 'Other' })).toBe(1); + }); +}); + +describe('singletonPages', () => { + it('lists singleton pages with where they sit, skipping lists', () => { + const pages = singletonPages(schema, [ + { + container: { + name: 'Mail & apps', + icon: 'mail', + items: [ + { + type: 'container', + name: 'Protocols', + items: [ + { type: 'link', name: 'IMAP', viewName: 'x:Imap' }, + { type: 'link', name: 'Tracers', viewName: 'x:Tracer' }, + ], + }, + { type: 'link', name: 'Limits', viewName: 'x:Email/EmailLimits' }, + ], + }, + }, + ]); + expect(pages).toEqual([ + { viewName: 'x:Imap', label: 'IMAP', where: 'Mail & apps › Protocols' }, + { viewName: 'x:Email/EmailLimits', label: 'Limits', where: 'Mail & apps' }, + ]); + }); +}); diff --git a/src/features/settings/changedFromDefault.ts b/src/features/settings/changedFromDefault.ts new file mode 100644 index 0000000..3a8a35e --- /dev/null +++ b/src/features/settings/changedFromDefault.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: which Settings pages hold values someone has changed from the + * default, for the Overview (settings-reorg). It counts exactly what a form + * marks as changed (FieldWidget): a field whose default can be put into words + * and whose value differs from it. + */ + +import { describeDefault, differsFromDefault } from '@/help/defaults'; +import { resolveForm, resolveObject, resolveSchema } from '@/lib/schemaResolver'; +import type { LayoutItem, LayoutSubItem, Schema } from '@/types/schema'; + +const WORDS = { on: 'on', off: 'off', none: 'none' }; + +export interface SettingsPageRef { + viewName: string; + label: string; + /** Category › group, for the list. */ + where: string; +} + +/** The singleton pages in a layout: the only ones with one value set to compare. */ +export function singletonPages(schema: Schema, items: LayoutItem[]): SettingsPageRef[] { + const out: SettingsPageRef[] = []; + const visit = (subs: LayoutSubItem[], where: string) => { + for (const sub of subs) { + if (sub.type === 'container') { + visit(sub.items, `${where} › ${sub.name}`); + } else if (resolveObject(schema, sub.viewName)?.objectType.type === 'singleton') { + out.push({ viewName: sub.viewName, label: sub.name, where }); + } + } + }; + for (const item of items) { + if ('container' in item) visit(item.container.items, item.container.name); + } + return out; +} + +/** How many of a page's fields differ from their defaults, given the object's current value. */ +export function countChanged(schema: Schema, viewName: string, data: Record): number { + const resolved = resolveObject(schema, viewName); + if (!resolved) return 0; + const sch = resolveSchema(schema, resolved.objectName); + if (!sch) return 0; + + let scope: string; + let schemaName: string; + if (sch.type === 'single') { + scope = resolved.objectName; + schemaName = sch.schemaName; + } else { + const variant = sch.variants.find((v) => v.name === data['@type']); + if (!variant?.schemaName) return 0; + scope = variant.schemaName; + schemaName = variant.schemaName; + } + + const fields = schema.fields[scope] ?? schema.fields[schemaName]; + const form = resolveForm(schema, viewName, resolved.objectName, schemaName); + if (!fields || !form) return 0; + + let changed = 0; + for (const section of form.sections) { + for (const { name } of section.fields) { + const field = fields.properties[name]; + if (!field) continue; + const def = fields.defaults?.[name]; + if (describeDefault(field, def, schema, WORDS) !== null && differsFromDefault(data[name], def)) changed++; + } + } + return changed; +} diff --git a/src/lib/settingsLayout.test.ts b/src/lib/settingsLayout.test.ts new file mode 100644 index 0000000..411db63 --- /dev/null +++ b/src/lib/settingsLayout.test.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { regroupSettings, SETTINGS_CATEGORIES, SETTINGS_OVERVIEW_VIEW, withRegroupedSettings } from './settingsLayout'; +import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema'; + +const served: Layout = { + name: 'Settings', + icon: 'sliders-horizontal', + items: [ + { + container: { + name: 'MTA', + icon: 'route', + items: [ + { + type: 'container', + name: 'Session', + items: [ + { type: 'link', name: 'EHLO Stage', viewName: 'x:MtaStageEhlo' }, + { type: 'link', name: 'Extensions', viewName: 'x:MtaExtensions' }, + ], + }, + { type: 'link', name: 'Sender Authentication', viewName: 'x:SenderAuth' }, + ], + }, + }, + { link: { name: 'Branding', icon: 'palette', viewName: 'x:Enterprise' } }, + { link: { name: 'Something New', icon: 'sparkles', viewName: 'x:SomethingNew' } }, + ], +}; + +function tree(items: (LayoutItem | LayoutSubItem)[]): unknown[] { + return items.map((it) => { + if ('link' in it) return it.link.name; + if ('container' in it) return { [it.container.name]: tree(it.container.items) }; + if (it.type === 'link') return it.name; + return { [`${it.name}${it.advanced ? ' (advanced)' : ''}`]: tree(it.items) }; + }); +} + +describe('regroupSettings', () => { + it('puts every page under its category, with plain labels, Overview first', () => { + expect(tree(regroupSettings(served).items)).toEqual([ + 'Overview', + { + 'Mail flow': [ + { Receiving: ['Sender checks (SPF, DKIM, DMARC)'] }, + { 'Advanced (advanced)': ['Greeting (EHLO)', 'SMTP extensions'] }, + ], + }, + { System: [{ Server: ['Branding'] }, { Other: ['Something New'] }] }, + ]); + }); + + it('keeps view names, so URLs do not move', () => { + const out = regroupSettings(served); + expect(out.items[0]).toEqual({ + link: { name: 'Overview', icon: 'layout-grid', viewName: SETTINGS_OVERVIEW_VIEW }, + }); + const views: string[] = []; + const walk = (items: (LayoutItem | LayoutSubItem)[]) => { + for (const it of items) { + if ('link' in it) views.push(it.link.viewName); + else if ('container' in it) walk(it.container.items); + else if (it.type === 'link') views.push(it.viewName); + else walk(it.items); + } + }; + walk(out.items); + expect(views.sort()).toEqual( + [ + SETTINGS_OVERVIEW_VIEW, + 'x:Enterprise', + 'x:MtaExtensions', + 'x:MtaStageEhlo', + 'x:SenderAuth', + 'x:SomethingNew', + ].sort(), + ); + }); + + it('leaves the other layouts alone', () => { + const management: Layout = { ...served, name: 'Management' }; + expect(regroupSettings(management)).toBe(management); + const schema = { layouts: [management, served] } as unknown as Schema; + expect(withRegroupedSettings(schema).layouts[0]).toBe(management); + }); + + it('fits the section bar', () => { + expect(SETTINGS_CATEGORIES.length + 1).toBeLessThanOrEqual(12); + }); + + it('places each page once', () => { + const all = SETTINGS_CATEGORIES.flatMap((c) => c.groups.flatMap((g) => g.pages.map((pg) => pg.viewName))); + expect(new Set(all).size).toBe(all.length); + }); +}); diff --git a/src/lib/settingsLayout.ts b/src/lib/settingsLayout.ts new file mode 100644 index 0000000..819e4c3 --- /dev/null +++ b/src/lib/settingsLayout.ts @@ -0,0 +1,388 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Settings, grouped by what someone comes to do rather than by the + * configuration file (spec: settings-reorg). The server's schema ships + * Settings as nineteen groups; this regroups the same pages into a handful of + * categories, short enough for the section bar. + * + * Only the menu changes. Every page keeps its view name, so its URL + * (/Settings/), bookmarks, last-visited and help links all stay. + * The server still decides which pages exist: a page listed here that the + * schema doesn't carry is left out, and a page the schema carries that isn't + * listed here lands in System › Other, so an upstream addition never vanishes. + */ + +import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema'; + +export const SETTINGS_LAYOUT_NAME = 'Settings'; +export const SETTINGS_OVERVIEW_VIEW = 'CustomComponent/SettingsOverview'; + +/** A page: its view, and a plainer label than the schema's where one helps. */ +export interface SettingsPage { + viewName: string; + label?: string; +} + +export interface SettingsGroup { + name: string; + /** Protocol-level detail most servers never touch: drawn last and dimmed. */ + advanced?: boolean; + pages: SettingsPage[]; +} + +export interface SettingsCategory { + name: string; + icon: string; + /** One sentence for the Overview card. */ + blurb: string; + groups: SettingsGroup[]; +} + +const p = (viewName: string, label?: string): SettingsPage => (label ? { viewName, label } : { viewName }); + +export const SETTINGS_CATEGORIES: SettingsCategory[] = [ + { + name: 'Mail flow', + icon: 'route', + blurb: 'How mail comes in and goes out: checks on senders, relays, limits and the reports sent to other servers.', + groups: [ + { + name: 'Receiving', + pages: [ + p('x:MtaInboundSession', 'Incoming connections'), + p('x:SenderAuth', 'Sender checks (SPF, DKIM, DMARC)'), + p('x:MtaSts', 'MTA-STS'), + ], + }, + { + name: 'Sending', + pages: [ + p('x:MtaOutboundStrategy', 'Delivery strategy'), + p('x:MtaRoute', 'Routes and relays'), + p('x:MtaDeliverySchedule', 'Retry schedules'), + p('x:MtaVirtualQueue', 'Queues'), + ], + }, + { + name: 'Limits', + pages: [ + p('x:MtaInboundThrottle', 'Incoming rate limits'), + p('x:MtaOutboundThrottle', 'Outgoing rate limits'), + p('x:MtaQueueQuota', 'Queue quotas'), + ], + }, + { + name: 'Reports', + pages: [ + p('x:ReportSettings', 'General'), + p('x:DmarcReportSettings', 'DMARC'), + p('x:TlsReportSettings', 'TLS'), + p('x:DkimReportSettings', 'DKIM'), + p('x:SpfReportSettings', 'SPF'), + p('x:DsnReportSettings', 'Bounce messages (DSN)'), + ], + }, + { + name: 'Filters & scripts', + pages: [ + p('x:MtaMilter', 'Milters'), + p('x:MtaHook', 'Hooks'), + p('x:SieveSystemScript', 'Server Sieve scripts'), + p('x:SieveSystemInterpreter', 'Server Sieve interpreter'), + ], + }, + { + name: 'Advanced', + advanced: true, + pages: [ + p('x:MtaStageConnect', 'Connection (Connect)'), + p('x:MtaStageEhlo', 'Greeting (EHLO)'), + p('x:MtaStageAuth', 'Sign-in (AUTH)'), + p('x:MtaStageMail', 'Sender (MAIL FROM)'), + p('x:MtaStageRcpt', 'Recipients (RCPT TO)'), + p('x:MtaStageData', 'Message (DATA)'), + p('x:MtaExtensions', 'SMTP extensions'), + p('x:MtaConnectionStrategy', 'Connection strategies'), + p('x:MtaTlsStrategy', 'TLS strategies'), + ], + }, + ], + }, + { + name: 'Spam filter', + icon: 'shield-alert', + blurb: 'How strict the filter is, what it learns from, and the lists of senders and domains it trusts or blocks.', + groups: [ + { + name: 'Protection', + pages: [p('x:SpamSettings', 'Filter settings'), p('x:SpamTag', 'Scores'), p('x:SpamRule', 'Rules')], + }, + { + name: 'Classifiers', + pages: [ + p('x:SpamClassifier', 'Statistical classifier'), + p('CustomComponent/LocalAi', 'Local AI'), + p('x:SpamLlm', 'LLM classifier'), + p('x:SpamPyzor', 'Pyzor'), + ], + }, + { + name: 'Blocklists (DNSBL)', + pages: [p('x:SpamDnsblSettings', 'Settings'), p('x:SpamDnsblServer', 'Servers')], + }, + { + name: 'Lists', + pages: [ + p('x:MemoryLookupKey/SpamTrustedDomain', 'Trusted domains'), + p('x:MemoryLookupKey/SpamBlockedDomain', 'Blocked domains'), + p('x:MemoryLookupKey/SpamTrap', 'Spam traps'), + p('x:MemoryLookupKey/SpamUrlRedirector', 'URL redirectors'), + p('x:SpamFileExtension', 'File extensions'), + ], + }, + ], + }, + { + name: 'Security', + icon: 'lock', + blurb: 'Who can sign in and how, the certificates that encrypt connections, and which addresses are blocked.', + groups: [ + { + name: 'Sign-in', + pages: [ + p('x:Authentication', 'General'), + p('x:Directory', 'Directories'), + p('x:OidcProvider', 'OIDC provider'), + ], + }, + { + name: 'Certificates', + pages: [p('x:Certificate', 'Certificates'), p('x:AcmeProvider', 'Automatic certificates (ACME)')], + }, + { + name: 'Protection', + pages: [ + p('x:Security', 'Settings'), + p('CustomComponent/LegacyProtocols', 'Hardening'), + p('x:BlockedIp', 'Blocked IPs'), + p('x:AllowedIp', 'Allowed IPs'), + p('x:Asn', 'ASN & GeoIP'), + ], + }, + ], + }, + { + name: 'Network', + icon: 'cable', + blurb: 'The ports the server listens on, its web server, and how it looks up and publishes DNS.', + groups: [ + { + name: 'Connections', + pages: [ + p('x:SystemSettings/NetworkSettings', 'General'), + p('x:NetworkListener', 'Listeners (ports)'), + p('x:SystemSettings/NetworkServices', 'Services'), + ], + }, + { + name: 'Web', + pages: [p('x:Http', 'HTTP'), p('x:Http/HttpSecurity', 'HTTP security'), p('x:HttpForm', 'Contact form')], + }, + { + name: 'DNS', + pages: [p('x:DnsServer', 'DNS providers'), p('x:DnsResolver', 'DNS resolver')], + }, + ], + }, + { + name: 'Mail & apps', + icon: 'mail', + blurb: 'What people get: mailbox defaults and limits, IMAP and JMAP, calendars, contacts, files and web apps.', + groups: [ + { + name: 'Email', + pages: [ + p('x:Email/EmailStorage', 'Storage'), + p('x:Email/EmailDefaults', 'Defaults'), + p('x:Email/EmailLimits', 'Email limits'), + p('x:Email/EmailEncryption', 'Encryption'), + ], + }, + { + name: 'Protocols', + pages: [ + p('x:Imap', 'IMAP'), + p('x:Jmap/JmapLimits', 'JMAP limits'), + p('x:Jmap/JmapPush', 'JMAP push'), + p('x:Jmap/JmapWebsocket', 'JMAP WebSocket'), + p('x:WebDav', 'WebDAV'), + ], + }, + { + name: 'Calendar & contacts', + pages: [ + p('x:Calendar', 'Calendar'), + p('x:CalendarScheduling', 'Scheduling'), + p('x:CalendarAlarm', 'Alarms'), + p('x:AddressBook', 'Address book'), + ], + }, + { + name: 'Files & sharing', + pages: [p('x:FileStorage', 'File storage'), p('x:Sharing', 'Sharing')], + }, + { + name: "People's filters", + pages: [p('x:SieveUserInterpreter', 'Sieve interpreter'), p('x:SieveUserScript', 'Sieve scripts')], + }, + { + name: 'Apps', + pages: [p('x:Application', 'Web applications')], + }, + ], + }, + { + name: 'Storage', + icon: 'database', + blurb: 'Where mail, files and indexes are kept, how long deleted and old data stays, and caching.', + groups: [ + { + name: 'Stores', + pages: [ + p('x:DataStore', 'Data store'), + p('x:BlobStore', 'Blobs (messages & files)'), + p('x:SearchStore', 'Search'), + p('x:InMemoryStore', 'In-memory'), + p('x:TracingStore', 'Tracing'), + p('x:MetricsStore', 'Metrics'), + ], + }, + { + name: 'Retention', + pages: [ + p('x:DataRetention/DataExpunge', 'Auto-expunge'), + p('x:DataRetention/DataCleanup', 'Data cleanup'), + p('x:DataRetention/ArchivingRetention', 'Archiving'), + p('x:DataRetention/TelemetryRetention', 'Telemetry'), + ], + }, + { + name: 'Performance', + pages: [p('x:Cache', 'Cache'), p('x:Search', 'Search indexing')], + }, + ], + }, + { + name: 'Monitoring', + icon: 'chart-line', + blurb: + 'Logs and traces, metrics for Prometheus or OpenTelemetry, and the alerts and webhooks that tell you things.', + groups: [ + { + name: 'Logs & traces', + pages: [p('x:Tracer', 'Tracers'), p('x:EventTracingLevel', 'Event levels')], + }, + { + name: 'Metrics', + pages: [ + p('x:Metrics', 'General'), + p('x:Metrics/CollectorOtel', 'OpenTelemetry'), + p('x:Metrics/CollectorPrometheus', 'Prometheus'), + ], + }, + { + name: 'Notifications', + pages: [p('x:Alert', 'Alerts'), p('x:WebHook', 'Webhooks')], + }, + ], + }, + { + name: 'System', + icon: 'boxes', + blurb: 'Running more than one node, background tasks, lookup tables, AI models and branding.', + groups: [ + { + name: 'Cluster', + pages: [p('x:Coordinator', 'Coordinator'), p('x:ClusterRole', 'Roles')], + }, + { + name: 'Server', + pages: [p('x:TaskManager', 'Task manager'), p('x:AiModel', 'AI models'), p('x:Enterprise', 'Branding')], + }, + { + name: 'Lookups', + pages: [ + p('x:HttpLookup', 'HTTP lists'), + p('x:StoreLookup', 'Store lookups'), + p('x:MemoryLookupKey', 'In-memory keys'), + p('x:MemoryLookupKeyValue', 'In-memory key-values'), + ], + }, + ], + }, +]; + +/** Where pages the table doesn't know about go. */ +const OTHER_CATEGORY = 'System'; +const OTHER_GROUP = 'Other'; + +/** Every link in a layout, view name → the schema's label, in schema order. */ +function collectLinks(items: (LayoutItem | LayoutSubItem)[], out: Map): Map { + for (const item of items) { + if ('link' in item) out.set(item.link.viewName, item.link.name); + else if ('container' in item) collectLinks(item.container.items, out); + else if (item.type === 'link') out.set(item.viewName, item.name); + else collectLinks(item.items, out); + } + return out; +} + +/** The Settings layout, regrouped. Other layouts pass through untouched. */ +export function regroupSettings(layout: Layout): Layout { + if (layout.name !== SETTINGS_LAYOUT_NAME) return layout; + const serverLinks = collectLinks(layout.items, new Map()); + const placed = new Set(); + + const categories = SETTINGS_CATEGORIES.map((cat) => { + const groups: LayoutSubItem[] = []; + for (const group of cat.groups) { + const links: LayoutSubItem[] = []; + for (const page of group.pages) { + const serverName = serverLinks.get(page.viewName); + if (serverName === undefined || placed.has(page.viewName)) continue; + placed.add(page.viewName); + links.push({ type: 'link', name: page.label ?? serverName, viewName: page.viewName }); + } + if (links.length > 0) { + groups.push({ type: 'container', name: group.name, items: links, ...(group.advanced && { advanced: true }) }); + } + } + return { name: cat.name, icon: cat.icon, groups }; + }); + + const leftovers: LayoutSubItem[] = []; + for (const [viewName, name] of serverLinks) { + if (!placed.has(viewName)) leftovers.push({ type: 'link', name, viewName }); + } + if (leftovers.length > 0) { + const home = categories.find((c) => c.name === OTHER_CATEGORY) ?? categories[categories.length - 1]; + home.groups.push({ type: 'container', name: OTHER_GROUP, items: leftovers }); + } + + const items: LayoutItem[] = [{ link: { name: 'Overview', icon: 'layout-grid', viewName: SETTINGS_OVERVIEW_VIEW } }]; + for (const cat of categories) { + if (cat.groups.length > 0) items.push({ container: { name: cat.name, icon: cat.icon, items: cat.groups } }); + } + return { ...layout, items }; +} + +/** The schema with its Settings layout regrouped; applied once, when the schema loads. */ +export function withRegroupedSettings(schema: Schema): Schema { + if (!schema.layouts.some((l) => l.name === SETTINGS_LAYOUT_NAME)) return schema; + return { ...schema, layouts: schema.layouts.map(regroupSettings) }; +} diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts index 8769f09..ce7f2b1 100644 --- a/src/stores/schemaStore.ts +++ b/src/stores/schemaStore.ts @@ -1,11 +1,15 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { create } from 'zustand'; import type { Schema, LayoutSubItem } from '@/types/schema'; +import { withRegroupedSettings } from '@/lib/settingsLayout'; export interface SearchIndexEntry { text: string; @@ -163,7 +167,9 @@ export const useSchemaStore = create()((set) => ({ viewToSection: {}, searchIndex: [], - setSchema: (schema) => { + setSchema: (served) => { + // inbuxa: Settings grouped by task, not by config file (settings-reorg). + const schema = withRegroupedSettings(served); const { viewToSection, linkEntries } = walkLayouts(schema); const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries); set({ diff --git a/src/types/schema.ts b/src/types/schema.ts index a83a6aa..b85a6ee 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import type { Dashboard } from '@/features/dashboard/types/schema'; @@ -369,6 +372,8 @@ export interface LayoutSubItemContainer { type: 'container'; name: string; items: LayoutSubItem[]; + /** inbuxa: protocol-level detail, drawn last and dimmed (settings-reorg). */ + advanced?: boolean; } export interface LayoutSubItemLink {