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..8e2dd24
--- /dev/null
+++ b/src/features/compliance/ComplianceOverviewPage.tsx
@@ -0,0 +1,244 @@
+/*
+ * 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 (
+
+ );
+}
+
+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 (
+
+
+
+
+ setFilter({ ...filter, kind: pick(v) as InventoryFilter['kind'] })}
+ >
+
+
+
+
+ {t('inventory.anyKind', 'Every source')}
+ {t('inventory.objects', 'Stored records')}
+ {t('inventory.sources', 'Files, exports and lookups')}
+
+
+ setFilter({ ...filter, category: pick(v) })}>
+
+
+
+
+ {t('inventory.anyCategory', 'Any kind of data')}
+ {CATEGORIES.map((c) => (
+
+ {c}
+
+ ))}
+
+
+ setFilter({ ...filter, where: pick(v) })}>
+
+
+
+
+ {t('inventory.anyPlace', 'Anywhere')}
+ {PLACES.map((p) => (
+
+ {whereName(p)}
+
+ ))}
+
+
+
+ setFilter({ ...filter, onlyCollected: v !== true })}
+ />
+ {t('inventory.showOff', 'Also show what this server doesn’t collect')}
+
+
+
+
+
+
+
+ {t('inventory.colWhat', 'What')}
+ {t('inventory.colHolds', 'Holds')}
+ {t('inventory.colWhose', 'Whose')}
+ {t('inventory.colWhere', 'Where')}
+ {t('inventory.colKept', 'Kept')}
+
+
+
+ {items.map((item) => (
+
+
+ {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..bb28e92
--- /dev/null
+++ b/src/features/compliance/inventory.test.ts
@@ -0,0 +1,73 @@
+/*
+ * 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');
+ expect(itemName('x:BlockedIp')).toBe('Blocked IP');
+ expect(itemName('x:DmarcTroubleshoot')).toBe('DMARC troubleshoot');
+ });
+
+ 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..8fe58a2
--- /dev/null
+++ b/src/features/compliance/inventory.ts
@@ -0,0 +1,240 @@
+/*
+ * 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',
+};
+
+/** 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):/, '');
+ // 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 = {
+ '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') {