From a9a239a24b57eacff8d2a79b88ddb70429e1d78a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 11:07:04 -0700 Subject: [PATCH 1/2] Compliance Overview and Data inventory pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pages under Management › Compliance, shown to anyone with sysComplianceGet (the Compliance Officer roles, and administrators). Data inventory lists every kind of personal data the catalog describes, evaluated against this server's settings: what it holds, whose it is, where it lives, how long it's kept and whether it leaves the server. Filters by source, kind of data and place, with what the server doesn't collect hidden unless asked for. Below the table, every host that receives some, as candidate processors; whether a host is one is the operator's call. Overview gives four counts (kinds held, kept with no limit, sent off this server, receiving hosts), the list of what has no limit and which setting could limit it, the last 30 days of successful changes to audit and log retention, tracers, webhooks, roles and legal holds with who made them (for those who can read the audit log), and the inventory snapshots with what changed between them. Both pages need a server that answers inbuxa:DataInventory/get; an older server gets a plain "doesn't report" message. The server schema adds the navigation entries, so this console goes out together with that server release. --- src/components/layout/MainContent.tsx | 16 ++ .../compliance/ComplianceOverviewPage.tsx | 241 ++++++++++++++++++ src/features/compliance/DataInventoryPage.tsx | 226 ++++++++++++++++ src/features/compliance/inventory.test.ts | 71 ++++++ src/features/compliance/inventory.ts | 201 +++++++++++++++ src/lib/layout.ts | 4 + 6 files changed, 759 insertions(+) create mode 100644 src/features/compliance/ComplianceOverviewPage.tsx create mode 100644 src/features/compliance/DataInventoryPage.tsx create mode 100644 src/features/compliance/inventory.test.ts create mode 100644 src/features/compliance/inventory.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 2b5b88b..55993ad 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -51,6 +51,16 @@ const LegacyProtocolsPage = lazyFeature( () => import('@/features/hardening/LegacyProtocolsPage'), (m) => m.LegacyProtocolsPage, ); +// inbuxa: Management › Compliance › Overview and Data Inventory +// (personal-data catalog spec, §8). +const ComplianceOverviewPage = lazyFeature( + () => import('@/features/compliance/ComplianceOverviewPage'), + (m) => m.ComplianceOverviewPage, +); +const DataInventoryPage = lazyFeature( + () => import('@/features/compliance/DataInventoryPage'), + (m) => m.DataInventoryPage, +); // inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec). const LocalAiPage = lazyFeature( () => import('@/features/ai/LocalAiPage'), @@ -151,6 +161,12 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LegalHolds') { return ; } + if (componentName === 'ComplianceOverview') { + return ; + } + if (componentName === 'DataInventory') { + return ; + } return (
Unknown component: {componentName} diff --git a/src/features/compliance/ComplianceOverviewPage.tsx b/src/features/compliance/ComplianceOverviewPage.tsx new file mode 100644 index 0000000..9a4b9db --- /dev/null +++ b/src/features/compliance/ComplianceOverviewPage.tsx @@ -0,0 +1,241 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Overview (personal-data catalog spec, + * §7, §8). What the server holds, in four counts, and what has no limit; + * recent changes that weaken review (audit retention, tracers, webhooks, + * roles, released holds), each with who and when; and the inventory's + * history. Facts, never a verdict. + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useAccountStore } from '@/stores/accountStore'; +import { EMPTY_FILTER, queryEvents, type AuditEvent } from '@/features/audit/auditLog'; +import { + fetchInventory, + fetchSnapshots, + InventoryUnavailable, + itemName, + unboundedItems, + type Inventory, + type Snapshot, + type Summary, +} from './inventory'; + +/** Changes that weaken or widen review, shown with who made them (§7). */ +const REVIEW_KINDS = [ + 'inbuxa:AuditSettings', + 'inbuxa:LogSettings', + 'x:Tracer', + 'x:WebHook', + 'x:Role', + 'inbuxa:LegalHold', +]; +const REVIEW_DAYS = 30; + +type Load = + | { kind: 'loading' } + | { kind: 'ready'; inventory: Inventory; snapshots: Snapshot[] } + | { kind: 'error'; message: string }; + +function Tile({ value, label }: { value: number; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function changeText(t: (k: string, d: string, o?: Record) => string, now: Summary, before?: Summary) { + if (!before) return t('overview.first', 'First record'); + const parts: string[] = []; + const diff = (key: keyof Summary, label: string) => { + const d = now[key] - before[key]; + if (d !== 0) parts.push(`${label} ${d > 0 ? '+' : ''}${d}`); + }; + diff('collected', t('overview.dCollected', 'collected')); + diff('unbounded', t('overview.dUnbounded', 'no limit')); + diff('leavingHost', t('overview.dLeaving', 'leaving')); + diff('processors', t('overview.dHosts', 'hosts')); + return parts.join(', ') || t('overview.noCount', 'Same counts'); +} + +export function ComplianceOverviewPage() { + const { t } = useTranslation(); + const canAudit = useAccountStore((s) => s.hasPermission('sysAuditGet')); + const [load, setLoad] = useState({ kind: 'loading' }); + const [review, setReview] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + Promise.all([fetchInventory(controller.signal), fetchSnapshots(controller.signal)]) + .then(([inventory, snapshots]) => !controller.signal.aborted && setLoad({ kind: 'ready', inventory, snapshots })) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof InventoryUnavailable + ? t('inventory.unavailable', 'This server doesn’t report a data inventory.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + if (canAudit) { + const from = new Date(Date.now() - REVIEW_DAYS * 86_400_000); + const day = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`; + Promise.all( + REVIEW_KINDS.map((targetKind) => + queryEvents({ ...EMPTY_FILTER, from: day, targetKind }, 0, controller.signal).catch(() => ({ + events: [] as AuditEvent[], + total: 0, + })), + ), + ).then((pages) => { + if (controller.signal.aborted) return; + const events = pages + .flatMap((p) => p.events) + .filter((e) => e.outcome.status === 'success') + .sort((a, b) => b.at.localeCompare(a.at)) + .slice(0, 20); + setReview(events); + }); + } + return () => controller.abort(); + }, [t, canAudit]); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + const { inventory, snapshots } = load; + const unbounded = unboundedItems(inventory.items); + + return ( +
+ + +
+ + + + +
+

+ + {t('overview.toInventory', 'See the data inventory')} + +

+ +
+

{t('overview.noLimitTitle', 'Kept with no time limit')}

+ {unbounded.length === 0 ? ( +

+ {t('overview.noLimitNone', 'Everything this server holds has a limit or goes with what it belongs to.')} +

+ ) : ( +
    + {unbounded.map((item) => ( +
  • + {itemName(item.id)} + + {'setting' in item.retention && item.retention.setting + ? t('overview.limitWith', 'A limit can be set: {{setting}}', { setting: item.retention.setting }) + : t('overview.noSetting', 'No setting limits it')} + +
  • + ))} +
+ )} +
+ + {canAudit && ( +
+

+ {t('overview.reviewTitle', 'Changes that affect review, last {{days}} days', { days: REVIEW_DAYS })} +

+

+ {t( + 'overview.reviewBody', + 'Audit and log retention, tracers, webhooks, roles and legal holds, with who changed them. Every one is in the audit log.', + )} +

+ {review === null ? ( + + ) : review.length === 0 ? ( +

+ {t('overview.reviewNone', 'None in this period.')} +

+ ) : ( +
    + {review.map((event) => ( +
  • + + {event.target.name || event.target.kind}{' '} + + {event.action} + {event.target.name && event.target.name !== event.target.kind && ` · ${event.target.kind}`} + {event.reason && ` · ${event.reason}`} + + + + {event.actor.name} · {new Date(event.at).toLocaleString()} + +
  • + ))} +
+ )} +
+ )} + +
+

{t('overview.historyTitle', 'When the picture changed')}

+ {snapshots.length === 0 ? ( +

+ {t( + 'overview.historyNone', + 'Nothing recorded yet. A record is made when a setting the inventory reads changes, and checked daily.', + )} +

+ ) : ( +
    + {snapshots.map((snap, i) => ( +
  • + + {new Date(snap.takenAt).toLocaleString()}{' '} + + {snap.trigger.kind === 'settingChanged' + ? t('overview.byChange', 'after {{setting}} changed', { setting: snap.trigger.setting }) + : t('overview.byDaily', 'daily check')} + + + {changeText(t, snap.summary, snapshots[i + 1]?.summary)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/features/compliance/DataInventoryPage.tsx b/src/features/compliance/DataInventoryPage.tsx new file mode 100644 index 0000000..03b29b0 --- /dev/null +++ b/src/features/compliance/DataInventoryPage.tsx @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Data Inventory (personal-data catalog + * spec, §8). What this server holds, evaluated from its settings: each kind + * of personal data, whose it is, where it lives, how long it's kept and + * whether it leaves the server; then every host that receives some. Facts + * only; whether a host is a processor is the operator's to determine. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { cn } from '@/lib/utils'; +import { + EMPTY_INVENTORY_FILTER, + fetchInventory, + filterItems, + InventoryUnavailable, + itemName, + retentionText, + whereName, + type Inventory, + type InventoryFilter, +} from './inventory'; + +const CATEGORIES = ['identifier', 'contact', 'network', 'content', 'metadata', 'credential']; +const PLACES = ['data-store', 'blob-store', 'search-store', 'in-memory-store', 'memory', 'log-file', 'external']; +const ANY = '__any'; + +type Load = { kind: 'loading' } | { kind: 'ready'; inventory: Inventory } | { kind: 'error'; message: string }; + +export function DataInventoryPage() { + const { t } = useTranslation(); + const [load, setLoad] = useState({ kind: 'loading' }); + const [filter, setFilter] = useState(EMPTY_INVENTORY_FILTER); + + useEffect(() => { + const controller = new AbortController(); + fetchInventory(controller.signal) + .then((inventory) => !controller.signal.aborted && setLoad({ kind: 'ready', inventory })) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof InventoryUnavailable + ? t('inventory.unavailable', 'This server doesn’t report a data inventory.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + return () => controller.abort(); + }, [t]); + + const items = useMemo(() => (load.kind === 'ready' ? filterItems(load.inventory.items, filter) : []), [load, filter]); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + const { inventory } = load; + const names = new Map(inventory.items.map((i) => [i.id, itemName(i.id)])); + const pick = (value: string) => (value === ANY ? '' : value); + + return ( +
+ + +
+ + + + +
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
{t('inventory.colWhat', 'What')}{t('inventory.colHolds', 'Holds')}{t('inventory.colWhose', 'Whose')}{t('inventory.colWhere', 'Where')}{t('inventory.colKept', 'Kept')}
+
{names.get(item.id)}
+
{item.id}
+ {!item.collected && ( + + {t('inventory.notCollected', 'Not collected here')} + + )} +
{item.categories.join(', ')}{item.whose.join(', ')} + {item.where.map(whereName).join(', ')} + {item.leavesHost && ( +
+ {t('inventory.leaves', 'Leaves this server')} + {item.endpoints.length > 0 && `: ${item.endpoints.join(', ')}`} +
+ )} +
+
{retentionText(item.retention)}
+ {'setting' in item.retention && item.retention.setting && item.retention.kind !== 'setting' && ( +
{item.retention.setting}
+ )} +
+ {items.length === 0 && ( +

{t('inventory.none', 'Nothing matches.')}

+ )} +
+ +
+

{t('inventory.hostsTitle', 'Hosts that receive personal data')}

+

+ {t( + 'inventory.hostsBody', + 'Each is a candidate processor. Whether it is one, and what agreement it needs, is for you to determine.', + )} +

+ {inventory.processors.length === 0 ? ( +

+ {t('inventory.noHosts', 'Nothing this server collects is sent to another host.')} +

+ ) : ( +
    + {inventory.processors.map((p) => ( +
  • + + + {p.host} + + + {p.receives.join(', ')} · {p.sources.map((s) => names.get(s) ?? s).join(', ')} + +
  • + ))} +
+ )} +
+ +

+ {t('inventory.evaluated', 'Worked out {{when}}, from the catalog in {{version}}.', { + when: inventory.evaluatedAt ? new Date(inventory.evaluatedAt).toLocaleString() : '', + version: inventory.catalogVersion, + })} +

+
+ ); +} diff --git a/src/features/compliance/inventory.test.ts b/src/features/compliance/inventory.test.ts new file mode 100644 index 0000000..2edeab0 --- /dev/null +++ b/src/features/compliance/inventory.test.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { + EMPTY_INVENTORY_FILTER, + filterItems, + itemName, + retentionText, + unboundedItems, + type InventoryItem, +} from './inventory'; + +const item = (patch: Partial): InventoryItem => ({ + id: 'x:UserAccount', + kind: 'object', + categories: ['identifier'], + whose: ['holder'], + where: ['data-store'], + scope: 'tenant', + collected: true, + retention: { kind: 'object-life' }, + leavesHost: false, + controlledBy: [], + endpoints: [], + ...patch, +}); + +describe('data inventory', () => { + it('names catalog ids for people', () => { + expect(itemName('x:UserAccount')).toBe('User account'); + expect(itemName('inbuxa:AuditEvent')).toBe('Audit event'); + expect(itemName('log-file')).toBe('Log files'); + expect(itemName('x:ArfFeedbackReport')).toBe('Arf feedback report'); + }); + + it('says retention as a fact', () => { + expect(retentionText({ kind: 'days', days: 30 })).toBe('Kept 30 days'); + expect(retentionText({ kind: 'days', days: 1 })).toBe('Kept 1 day'); + expect(retentionText({ kind: 'unbounded' })).toBe('Kept with no limit'); + expect(retentionText({ kind: 'setting', setting: 'x:Jmap.uploadTtl' })).toBe('Set by x:Jmap.uploadTtl'); + }); + + it('filters by kind, category, place, and what is collected', () => { + const items = [ + item({ id: 'a' }), + item({ id: 'b', kind: 'source', categories: ['network'], where: ['log-file'] }), + item({ id: 'c', collected: false }), + item({ id: 'd', where: ['data-store'], leavesHost: true, endpoints: ['db.example.net'] }), + ]; + const ids = (f: Partial) => + filterItems(items, { ...EMPTY_INVENTORY_FILTER, ...f }).map((i) => i.id); + expect(ids({})).toEqual(['a', 'b', 'd']); + expect(ids({ onlyCollected: false })).toEqual(['a', 'b', 'c', 'd']); + expect(ids({ kind: 'source' })).toEqual(['b']); + expect(ids({ category: 'network' })).toEqual(['b']); + expect(ids({ where: 'external' })).toEqual(['d']); + }); + + it('lists what is kept with no limit', () => { + const items = [ + item({ id: 'a', retention: { kind: 'unbounded' } }), + item({ id: 'b', retention: { kind: 'unbounded' }, collected: false }), + item({ id: 'c', retention: { kind: 'days', days: 3 } }), + ]; + expect(unboundedItems(items).map((i) => i.id)).toEqual(['a']); + }); +}); diff --git a/src/features/compliance/inventory.ts b/src/features/compliance/inventory.ts new file mode 100644 index 0000000..7a3d26e --- /dev/null +++ b/src/features/compliance/inventory.ts @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:DataInventory` and `inbuxa:InventorySnapshot`, the + * personal-data catalog evaluated against this server, and its history + * (personal-data catalog spec, §6, §8). Facts only: what is held, where, + * for how long, and which hosts receive it. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; + +const CAPABILITY = 'urn:inbuxa:jmap'; + +export type Retention = + | { kind: 'days'; days: number; setting?: string } + | { kind: 'unbounded'; setting?: string } + | { kind: 'object-life' } + | { kind: 'receiver' } + | { kind: 'setting'; setting: string }; + +export interface InventoryItem { + id: string; + kind: 'object' | 'source'; + categories: string[]; + whose: string[]; + where: string[]; + scope: 'tenant' | 'server'; + collected: boolean; + retention: Retention; + leavesHost: boolean; + controlledBy: string[]; + endpoints: string[]; +} + +export interface Processor { + host: string; + receives: string[]; + sources: string[]; +} + +export interface Summary { + collected: number; + unbounded: number; + leavingHost: number; + processors: number; +} + +export interface Inventory { + evaluatedAt: string; + catalogVersion: string; + summary: Summary; + items: InventoryItem[]; + processors: Processor[]; +} + +export interface Snapshot { + id: string; + takenAt: string; + trigger: { kind: 'settingChanged'; setting: string } | { kind: 'daily' }; + summary: Summary; +} + +/** The server doesn't evaluate the catalog (an older server). */ +export class InventoryUnavailable extends Error {} + +function failure(name: string | undefined, result: unknown): Error { + const r = result as { type?: string; description?: string } | undefined; + if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new InventoryUnavailable(r.type); + return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`); +} + +const EMPTY_SUMMARY: Summary = { collected: 0, unbounded: 0, leavingHost: 0, processors: 0 }; + +export async function fetchInventory(signal?: AbortSignal): Promise { + const responses = await jmapRequest( + [['inbuxa:DataInventory/get', { accountId: getAccountId('x:Account'), ids: null }, '0']], + signal, + [CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== 'inbuxa:DataInventory/get') throw failure(name, result); + const raw = ((result as { list?: Record[] }).list ?? [])[0] ?? {}; + return { + evaluatedAt: String(raw.evaluatedAt ?? ''), + catalogVersion: String(raw.catalogVersion ?? ''), + summary: { ...EMPTY_SUMMARY, ...((raw.summary as Summary | undefined) ?? {}) }, + items: (raw.items as InventoryItem[] | undefined) ?? [], + processors: (raw.processors as Processor[] | undefined) ?? [], + }; +} + +/** Every snapshot kept, newest first, without their inventories. */ +export async function fetchSnapshots(signal?: AbortSignal): Promise { + const responses = await jmapRequest( + [ + [ + 'inbuxa:InventorySnapshot/get', + { accountId: getAccountId('x:Account'), ids: null, properties: ['id', 'takenAt', 'trigger', 'summary'] }, + '0', + ], + ], + signal, + [CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== 'inbuxa:InventorySnapshot/get') throw failure(name, result); + return ((result as { list?: Snapshot[] }).list ?? []).map((s) => ({ + ...s, + summary: { ...EMPTY_SUMMARY, ...(s.summary ?? {}) }, + })); +} + +const SOURCE_NAMES: Record = { + 'mail-and-groupware': 'Mail, calendars, contacts and files', + 'full-text-index': 'Search index', + 'trace-index': 'Delivery history search index', + 'log-file': 'Log files', + 'console-and-journal': 'Console and system journal output', + 'otel-tracer': 'OpenTelemetry export', + webhooks: 'Webhooks', + 'spam-trainer-state': 'Spam classifier training state', + 'spam-llm': 'AI spam classification', + 'explain-cache': 'Explain answers (in memory)', + 'spam-dnsbl': 'Blocklist lookups', + 'spam-pyzor': 'Pyzor lookups', + 'spam-url-redirects': 'Short-link lookups', + 'mta-milter-and-hooks': 'Milters and MTA hooks', + relay: 'Relay (smart host)', + 'push-subscriptions': 'Push subscriptions', + 'rate-limit-and-greylist': 'Rate limits and greylisting', + 'legacy-last-use': 'Legacy mail app last use', + 'masked-address-records': 'Masked address records', + 'outbound-reports': 'DMARC and TLS reports sent', +}; + +/** A readable name for a catalog id. */ +export function itemName(id: string): string { + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; + const bare = id.replace(/^(x|inbuxa):/, ''); + // UserAccount -> User account + const words = bare.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'); + return words.charAt(0) + words.slice(1).toLowerCase(); +} + +const WHERE_NAMES: Record = { + 'data-store': 'data store', + 'blob-store': 'blob store', + 'search-store': 'search store', + 'in-memory-store': 'in-memory store', + memory: 'memory', + 'log-file': 'log files', + external: 'sent elsewhere', +}; + +export function whereName(where: string): string { + return WHERE_NAMES[where] ?? where; +} + +/** A retention as a fact, in words. */ +export function retentionText(retention: Retention): string { + switch (retention.kind) { + case 'days': + return retention.days === 1 ? 'Kept 1 day' : `Kept ${retention.days} days`; + case 'unbounded': + return 'Kept with no limit'; + case 'object-life': + return 'Kept while it exists'; + case 'receiver': + return 'Kept by whoever receives it'; + case 'setting': + return `Set by ${retention.setting}`; + } +} + +export interface InventoryFilter { + kind: '' | 'object' | 'source'; + category: string; + where: string; + onlyCollected: boolean; +} + +export const EMPTY_INVENTORY_FILTER: InventoryFilter = { kind: '', category: '', where: '', onlyCollected: true }; + +export function filterItems(items: InventoryItem[], filter: InventoryFilter): InventoryItem[] { + return items.filter( + (item) => + (!filter.kind || item.kind === filter.kind) && + (!filter.category || item.categories.includes(filter.category)) && + (!filter.where || item.where.includes(filter.where) || (filter.where === 'external' && item.leavesHost)) && + (!filter.onlyCollected || item.collected), + ); +} + +/** Collected items kept with no limit, for the Overview. */ +export function unboundedItems(items: InventoryItem[]): InventoryItem[] { + return items.filter((i) => i.collected && i.retention.kind === 'unbounded'); +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index f2d7d7f..a56f185 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -85,6 +85,10 @@ function checkSpecialLink( if (viewName === 'CustomComponent/LegalHolds') { return { visible: canGet ? canGet('sysLegalHold') : true, enterprise: false }; } + // INBUXA: the compliance overview and data inventory (personal-data catalog) + if (viewName === 'CustomComponent/ComplianceOverview' || viewName === 'CustomComponent/DataInventory') { + return { visible: canGet ? canGet('sysCompliance') : true, enterprise: false }; + } // inbuxa: locked accounts, for whoever may see them (AL-12). if (viewName === 'CustomComponent/AccountLocks') { -- 2.54.0 From bf07dd7bc001572972967ec205f59e5f083496b1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 11:10:05 -0700 Subject: [PATCH 2/2] Item names keep IP, DKIM, DMARC and the like in capitals --- .../compliance/ComplianceOverviewPage.tsx | 5 ++- src/features/compliance/inventory.test.ts | 4 +- src/features/compliance/inventory.ts | 45 +++++++++++++++++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/features/compliance/ComplianceOverviewPage.tsx b/src/features/compliance/ComplianceOverviewPage.tsx index 9a4b9db..8e2dd24 100644 --- a/src/features/compliance/ComplianceOverviewPage.tsx +++ b/src/features/compliance/ComplianceOverviewPage.tsx @@ -137,7 +137,10 @@ export function ComplianceOverviewPage() { />
- + diff --git a/src/features/compliance/inventory.test.ts b/src/features/compliance/inventory.test.ts index 2edeab0..bb28e92 100644 --- a/src/features/compliance/inventory.test.ts +++ b/src/features/compliance/inventory.test.ts @@ -34,7 +34,9 @@ describe('data inventory', () => { expect(itemName('x:UserAccount')).toBe('User account'); expect(itemName('inbuxa:AuditEvent')).toBe('Audit event'); expect(itemName('log-file')).toBe('Log files'); - expect(itemName('x:ArfFeedbackReport')).toBe('Arf feedback report'); + expect(itemName('x:ArfFeedbackReport')).toBe('ARF feedback report'); + expect(itemName('x:BlockedIp')).toBe('Blocked IP'); + expect(itemName('x:DmarcTroubleshoot')).toBe('DMARC troubleshoot'); }); it('says retention as a fact', () => { diff --git a/src/features/compliance/inventory.ts b/src/features/compliance/inventory.ts index 7a3d26e..8fe58a2 100644 --- a/src/features/compliance/inventory.ts +++ b/src/features/compliance/inventory.ts @@ -137,13 +137,52 @@ const SOURCE_NAMES: Record = { 'outbound-reports': 'DMARC and TLS reports sent', }; +/** Words in object names that are written in capitals. */ +const ACRONYMS: Record = Object.fromEntries( + [ + 'AI', + 'API', + 'ARF', + 'ASN', + 'CSV', + 'DAV', + 'DKIM', + 'DMARC', + 'DNS', + 'DNSBL', + 'DSN', + 'EHLO', + 'HTTP', + 'IMAP', + 'IP', + 'JMAP', + 'LDAP', + 'LLM', + 'MTA', + 'MX', + 'OIDC', + 'OTP', + 'SPF', + 'SQL', + 'STS', + 'TLS', + 'TSIG', + 'TTL', + 'URL', + ].map((a) => [a.toLowerCase(), a]), +); + /** A readable name for a catalog id. */ export function itemName(id: string): string { if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; const bare = id.replace(/^(x|inbuxa):/, ''); - // UserAccount -> User account - const words = bare.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'); - return words.charAt(0) + words.slice(1).toLowerCase(); + // BlockedIp -> Blocked IP + const words = bare + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') + .split(' ') + .map((w, i) => ACRONYMS[w.toLowerCase()] ?? (i === 0 ? w : w.toLowerCase())); + return words.join(' '); } const WHERE_NAMES: Record = { -- 2.54.0