From 0fe2c39c95d4d70707e0f795d6faa977f22561f3 Mon Sep 17 00:00:00 2001 From: David Condrey Date: Wed, 5 Aug 2026 00:32:14 -0700 Subject: [PATCH] feat(dashboard): make the CRM access log readable, and stop it hiding its horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log had no reader. It also answered a paged list with no statement of what the page could not contain, which is the defect this fixes on the server side. THE HORIZON. The audit log is the one CRM table that ages out, so an empty result means either that nothing happened or that it happened too long ago — and the rows alone cannot tell those apart. An auditor reading the first when the second is true has drawn exactly the wrong conclusion from an access log. `GET /api/crm/audit` now returns `retention_days` and `covers_since` with every page, and the panel renders "nothing before X is retained". This is the same rule the rollup cap and the export cap already follow: never a silent truncation, by count or by time. `covers_since` is the guarantee rather than the oldest row, since purging runs hourly and slightly older entries may still be present. THE ACTOR. Entries store `actor_user_id` because it is stable and leaves no email behind once an account is closed, but "operator 8f3a1c…" is a record, not accountability. The response resolves it to `actor_email` at read time, so the name follows the account rather than being frozen at write time, and is null once the account is gone — the id stays either way, so an entry always names someone specific. This is a deliberate disclosure: it tells a team admin the addresses of the colleagues who read this site's contacts, which no other session-reachable route does. Everyone who can appear holds a role on that admin's own team, because that is what authorized the access being reported. The resolve chunks at D1_MAX_IN_PARAMS. One page can name at most 100 distinct actors and D1's ceiling is exactly 100 bound parameters, so a single lookup would sit precisely on the cliff and stay correct only while two unrelated limits keep their current relationship. THE PANEL states the other thing the rows invite a reader to get wrong: an entry records that an operator was authorized to do this, not that it succeeded. A run of reads against ids that do not exist is someone probing, not someone being shown anything. Erasures and exports are weighted so they do not read as more list traffic. `target_id` is shown raw and never resolved to a name — the log holds no contact fields and after an erasure the id points at nothing, so the honest offer is "filter by this id", not a label the log cannot stand behind. From a contact or company, "Access log" opens the log filtered to that record — "who has looked at this person" is the question a subject-access request or a suspected leak asks, and a whole-site log answers it only by being read end to end. Also, autonomously: `CrmAccessNotice` takes a per-surface 403, because the shared wording names `analyst` and says the reader has no CRM access, both wrong for a surface gated on `admin` — a refusal that states the wrong requirement sends the reader to ask for a role that would not have helped. Every CRM mutation now invalidates the log, since every one of them writes an entry. --- apps/dashboard/src/components/Crm.tsx | 24 +- .../src/components/crm/AuditPanel.tsx | 326 ++++++++++++++++++ .../src/components/crm/CompaniesPanel.tsx | 4 + .../src/components/crm/CompanyDetail.tsx | 15 +- .../src/components/crm/ContactDetail.tsx | 17 +- .../src/components/crm/ContactsPanel.tsx | 4 + apps/dashboard/src/components/crm/shared.tsx | 10 +- apps/dashboard/src/hooks/crm.ts | 73 +++- apps/dashboard/src/lib/crm.ts | 78 +++++ apps/dashboard/src/test/crm-audit.test.tsx | 257 ++++++++++++++ apps/server/src/lib/accounts.ts | 32 +- apps/server/src/routes/crm.ts | 42 ++- apps/server/test/crm-audit.test.ts | 99 ++++++ docs/api.md | 11 +- 14 files changed, 978 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/src/components/crm/AuditPanel.tsx create mode 100644 apps/dashboard/src/test/crm-audit.test.tsx diff --git a/apps/dashboard/src/components/Crm.tsx b/apps/dashboard/src/components/Crm.tsx index 7d6d9dd..ca7f9dc 100644 --- a/apps/dashboard/src/components/Crm.tsx +++ b/apps/dashboard/src/components/Crm.tsx @@ -18,14 +18,19 @@ import { type ReactElement, useState } from 'react'; import { cn } from '../lib/cn.js'; import { SegmentNotice } from './CubeFilterBar.js'; +import { AuditPanel } from './crm/AuditPanel.js'; import { CompaniesPanel } from './crm/CompaniesPanel.js'; import { ContactsPanel } from './crm/ContactsPanel.js'; -type Section = 'contacts' | 'companies'; +type Section = 'contacts' | 'companies' | 'audit'; const SECTIONS: { id: Section; label: string }[] = [ { id: 'contacts', label: 'Contacts' }, { id: 'companies', label: 'Companies' }, + // Always offered, never hidden behind a role check. The browser cannot prove its own role until a + // list response reports one, and a tab that appears late is worse than a tab that explains itself: + // the panel answers a 403 by naming the role it needs, which is the thing the reader has to know. + { id: 'audit', label: 'Access log' }, ]; /** Roving-tabindex arrow navigation, as `role="tablist"` promises to assistive tech. Returns true @@ -48,10 +53,11 @@ function onSectionKey(key: string, current: Section, select: (id: Section) => vo export function Crm({ siteId }: { siteId: string }): ReactElement { const [section, setSection] = useState
('contacts'); - // Both selections live here so the two panels can hand off to each other: a contact's employer - // opens the company, and a company's roster opens the person. + // Every selection lives here so the panels can hand off to each other: a contact's employer opens + // the company, a company's roster opens the person, and either one opens its own access history. const [contactId, setContactId] = useState(''); const [companyId, setCompanyId] = useState(''); + const [auditTarget, setAuditTarget] = useState(''); const openCompany = (id: string): void => { setCompanyId(id); @@ -61,6 +67,12 @@ export function Crm({ siteId }: { siteId: string }): ReactElement { setContactId(id); if (id) setSection('contacts'); }; + /** Open the log filtered to one record — the question a subject-access request or a suspected + * leak actually asks, and one a page of every access cannot answer. */ + const openAudit = (id: string): void => { + setAuditTarget(id); + setSection('audit'); + }; return (
@@ -116,14 +128,18 @@ export function Crm({ siteId }: { siteId: string }): ReactElement { selectedId={contactId} onSelect={setContactId} onOpenCompany={openCompany} + onOpenAudit={openAudit} /> - ) : ( + ) : section === 'companies' ? ( + ) : ( + )}
diff --git a/apps/dashboard/src/components/crm/AuditPanel.tsx b/apps/dashboard/src/components/crm/AuditPanel.tsx new file mode 100644 index 0000000..8acee93 --- /dev/null +++ b/apps/dashboard/src/components/crm/AuditPanel.tsx @@ -0,0 +1,326 @@ +// The access log: who touched this site's contacts, what they touched, and when. +// +// Two things about this data are easy to misread, so the panel states both rather than leaving the +// reader to infer them from a table: +// +// • AN ENTRY IS AN AUTHORIZED ATTEMPT, NOT A SUCCESS. The server writes it before the handler runs, +// which is what makes an unrecorded access impossible — and the cost is that a request which then +// found nothing looks identical to one that returned a record. Read as "succeeded", a run of +// probes against ids that do not exist becomes a run of disclosures that never happened. +// • THE LOG HAS A HORIZON. It is the one CRM table on a retention schedule, so an empty result means +// either that nothing happened or that it happened too long ago. The server reports the window +// with every page precisely so this panel can say which. +// +// `target_id` is shown raw and never resolved to a name. The log holds no contact fields by design, +// and after an erasure the id points at nothing — so the honest offer is "filter by this id", not a +// label the log cannot stand behind. + +import { ArrowLeft, ScrollText } from 'lucide-react'; +import { type ReactElement, useState } from 'react'; +import { CRM_PAGE_SIZE, useCrmAudit } from '../../hooks/crm.js'; +import { cn } from '../../lib/cn.js'; +import { type AuditTone, auditActionText, auditTone } from '../../lib/crm.js'; +import { formatDateTime } from '../../lib/datetime.js'; +import { CardSkeletons, EmptyState } from '../StatusStates.js'; +import { CrmAccessNotice, Pager } from './shared.js'; + +/** Every action, for the filter. Ordered by subject then by how much the act discloses, so the two + * an auditor scans for — the export and the erasures — are not buried mid-list. */ +const ACTIONS: string[] = [ + 'contact.export', + 'contact.delete', + 'company.delete', + 'contact.list', + 'contact.read', + 'contact.create', + 'contact.update', + 'contact.analytics', + 'company.list', + 'company.read', + 'company.create', + 'company.update', + 'company.contacts', + 'company.analytics', + 'audit.read', +]; + +const TONE_CLASS: Record = { + erase: 'alert-error', + export: 'alert-warn', + write: 'chip-active', + read: '', +}; + +/** The act, weighted by what it was. An erasure and a list read are both "an access" and are not the + * same event; colour only reinforces a word that already says so. */ +function ActionCell({ action }: { action: string }): ReactElement { + const tone = auditTone(action); + return ( + + {auditActionText(action)} + + ); +} + +export function AuditPanel({ + siteId, + targetId, + onTarget, +}: { + siteId: string; + /** Set when the reader arrived from a contact or company, asking about that record specifically. */ + targetId: string; + onTarget: (id: string) => void; +}): ReactElement { + const [action, setAction] = useState(''); + const [actorUserId, setActorUserId] = useState(''); + const [offset, setOffset] = useState(0); + + const log = useCrmAudit(siteId, { action, targetId, actorUserId, offset }); + + if (log.error) { + return ( + + A level above what reading contacts needs, and not because the log holds + more: nothing in it is contact data — every entry is an id, a role, an + action and a time. It is that what it reports is your{' '} + colleagues, and a record of what each person read is + oversight in an administrator’s hands and surveillance in a + peer’s. + + ), + }} + onRetry={() => void log.refetch()} + retrying={log.isFetching} + /> + ); + } + + const entries = log.data?.entries ?? []; + const total = log.data?.total ?? 0; + const filtering = Boolean(action || targetId || actorUserId); + const clear = (): void => { + setAction(''); + setActorUserId(''); + onTarget(''); + setOffset(0); + }; + + return ( +
+
+
+ + +
+ {filtering ? ( + + ) : null} +
+ + {targetId ? ( +

+ Showing every recorded access to {targetId}. + Entries survive the record they name — after an erasure the id resolves to + nothing and the history of who read it remains. +

+ ) : null} + {actorUserId ? ( +

+ Showing one operator’s activity.{' '} + +

+ ) : null} + + {log.isLoading ? ( + + ) : entries.length === 0 ? ( + + {filtering ? ( + <>Clear the filters to see every recorded access. + ) : ( + <> + Every authorized request to a contact or company is recorded here before + it runs — including reads, which otherwise leave no trace at all. + + )} + + ) : ( + <> +
+ + + + + {['When', 'Who', 'Did', 'To'].map((label) => ( + + ))} + + + + {entries.map((entry) => ( + + + + + + + ))} + +
+ Recorded accesses to this site’s contacts and companies. +
+ {label} +
+ {formatDateTime(entry.occurred_at)} + + + + as {entry.actor_role} + {entry.actor_email ? null : ' · account closed'} + + + + + {entry.target_id ? ( + + ) : ( + + — + + )} +
+
+ + + )} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/crm/CompaniesPanel.tsx b/apps/dashboard/src/components/crm/CompaniesPanel.tsx index 640ff1f..ef677e5 100644 --- a/apps/dashboard/src/components/crm/CompaniesPanel.tsx +++ b/apps/dashboard/src/components/crm/CompaniesPanel.tsx @@ -18,12 +18,15 @@ export function CompaniesPanel({ selectedId, onSelect, onOpenContact, + onOpenAudit, }: { siteId: string; /** Owned by the tab shell so a contact's company link can select one from the other panel. */ selectedId: string; onSelect: (companyId: string) => void; onOpenContact: (contactId: string) => void; + /** Open the access log filtered to one company. */ + onOpenAudit: (targetId: string) => void; }): ReactElement { // The role the server served this list under — the only authoritative answer available to // the browser. See `canAdministerCrm`. @@ -252,6 +255,7 @@ export function CompaniesPanel({ company={selected.data.company} canAdminister={canAdminister} onOpenContact={onOpenContact} + onOpenAudit={onOpenAudit} onDeleted={(unlinked) => { setDeleted( unlinked === 1 diff --git a/apps/dashboard/src/components/crm/CompanyDetail.tsx b/apps/dashboard/src/components/crm/CompanyDetail.tsx index e003a79..471aef0 100644 --- a/apps/dashboard/src/components/crm/CompanyDetail.tsx +++ b/apps/dashboard/src/components/crm/CompanyDetail.tsx @@ -6,7 +6,7 @@ // contacts_total` is rendered above the figures on every response — linked or not — and a truncated // rollup says it is a lower bound rather than presenting a capped sum as a total. -import { Link2Off, Users } from 'lucide-react'; +import { Link2Off, ScrollText, Users } from 'lucide-react'; import { type ReactElement, useState } from 'react'; import { CRM_PAGE_SIZE, @@ -189,6 +189,7 @@ export function CompanyDetail({ canAdminister, onDeleted, onOpenContact, + onOpenAudit, }: { siteId: string; company: CrmCompany; @@ -196,6 +197,8 @@ export function CompanyDetail({ canAdminister: boolean; onDeleted: (contactsUnlinked: number) => void; onOpenContact: (contactId: string) => void; + /** Open the access log filtered to this company. */ + onOpenAudit?: (targetId: string) => void; }): ReactElement { const [editing, setEditing] = useState(false); const [rosterOffset, setRosterOffset] = useState(0); @@ -246,6 +249,16 @@ export function CompanyDetail({
+ {onOpenAudit ? ( + + ) : null}
+ {onOpenAudit ? ( + + ) : null}