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}