Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const AllSites = lazy(() =>
const Anomalies = lazy(() =>
import('./components/Anomalies.js').then((m) => ({ default: m.Anomalies })),
);
const Crm = lazy(() => import('./components/Crm.js').then((m) => ({ default: m.Crm })));
const AskPanel = lazy(() =>
import('./components/AskPanel.js').then((m) => ({ default: m.AskPanel })),
);
Expand Down Expand Up @@ -92,6 +93,7 @@ type View =
| 'retention'
| 'experiments'
| 'anomalies'
| 'crm'
| 'ask'
| 'docs';

Expand Down Expand Up @@ -167,6 +169,7 @@ const TABS: { id: View; label: string }[] = [
{ id: 'retention', label: 'Retention' },
{ id: 'experiments', label: 'Experiments' },
{ id: 'anomalies', label: 'Anomalies' },
{ id: 'crm', label: 'CRM' },
{ id: 'ask', label: 'Ask' },
{ id: 'docs', label: 'Documentation' },
];
Expand Down Expand Up @@ -620,6 +623,8 @@ function Dashboard(): ReactElement {
range={range}
onInvestigate={investigate}
/>
) : view === 'crm' ? (
<Crm siteId={siteId} />
) : view === 'ask' ? (
<AskPanel apiKey={apiKey} siteId={siteId} range={range} />
) : (
Expand Down
44 changes: 44 additions & 0 deletions apps/dashboard/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,47 @@ export async function apiPost<T>(path: string, apiKey: string, body: unknown): P
export function fetchStats(apiKey: string, query: StatsQuery): Promise<StatsResponse> {
return apiFetch<StatsResponse>(`/api/stats?${qs(query)}`, apiKey);
}

/**
* The error code for a failed response: the API's own `{ error }` when it sent one, otherwise
* derived from the status. The fallback matters for the session routes, whose whole UI hinges on
* telling a 501 (this deployment has no CRM database) apart from a 403 (your role is too low) — a
* proxy or a truncated body must not collapse both into an indistinguishable "request_failed".
*/
function errorCode(status: number, body: { error?: string }): string {
if (body.error) return body.error;
if (status === 501) return 'crm_unavailable';
if (status === 403) return 'forbidden';
if (status === 401) return 'unauthorized';
return 'request_failed';
}

/**
* Canonical helper for the SESSION-authenticated API (`/api/auth/me`, `/api/crm/*`).
*
* Deliberately sends no `Authorization` header: those routes refuse an API key by design, because a
* `clk_` key reads aggregate analytics and is handed out accordingly while contact PII is not. Auth
* is the HttpOnly session cookie, which a same-origin request carries on its own — `same-origin` is
* the browser default and is stated here so the intent survives the next refactor.
*/
export async function sessionFetch<T>(
path: string,
init?: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown },
): Promise<T> {
const method = init?.method ?? 'GET';
const res = await fetch(path, {
method,
credentials: 'same-origin',
...(init?.body === undefined
? {}
: {
headers: { 'content-type': 'application/json' },
body: JSON.stringify(init.body),
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorCode(res.status, body));
}
return (await res.json()) as T;
}
131 changes: 131 additions & 0 deletions apps/dashboard/src/components/Crm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// The CRM tab: contacts and companies, the one surface in this dashboard that holds directly
// identifying personal data.
//
// It is authenticated differently from every other tab, and that is deliberate rather than an
// oversight to fix. Everything else here reads with a per-site `clk_` API key; these routes refuse
// one, because a key that leaks costs you aggregate pageview counts while a key that could read
// contacts would cost you your customers' names, emails and phone numbers. So the CRM is gated on an
// operator session cookie plus a team role, and the panels below explain that rather than failing
// with a bare 401.
//
// Two states dominate what this tab renders in practice:
// • NO CRM DATABASE (501). The extension is an optional second D1 binding that most deployments
// never enable, so this is the DEFAULT and it renders as an explanation, never as an error.
// • ROLE. `viewer` sees nothing here; `analyst` reads and writes; `admin` deletes and exports.
// The destructive controls are hidden rather than offered and then refused — see
// `canAdministerCrm` for what the browser can actually prove about the operator's role.

import { type ReactElement, useState } from 'react';
import { cn } from '../lib/cn.js';
import { SegmentNotice } from './CubeFilterBar.js';
import { CompaniesPanel } from './crm/CompaniesPanel.js';
import { ContactsPanel } from './crm/ContactsPanel.js';

type Section = 'contacts' | 'companies';

const SECTIONS: { id: Section; label: string }[] = [
{ id: 'contacts', label: 'Contacts' },
{ id: 'companies', label: 'Companies' },
];

/** Roving-tabindex arrow navigation, as `role="tablist"` promises to assistive tech. Returns true
* when the key was consumed, so the caller can suppress the page scroll. */
function onSectionKey(key: string, current: Section, select: (id: Section) => void): boolean {
const index = SECTIONS.findIndex((s) => s.id === current);
if (index < 0) return false;
let next: number;
if (key === 'ArrowRight') next = (index + 1) % SECTIONS.length;
else if (key === 'ArrowLeft') next = (index - 1 + SECTIONS.length) % SECTIONS.length;
else if (key === 'Home') next = 0;
else if (key === 'End') next = SECTIONS.length - 1;
else return false;
const target = SECTIONS[next];
if (!target) return false;
select(target.id);
document.getElementById(`crm-tab-${target.id}`)?.focus();
return true;
}

export function Crm({ siteId }: { siteId: string }): ReactElement {
const [section, setSection] = useState<Section>('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.
const [contactId, setContactId] = useState('');
const [companyId, setCompanyId] = useState('');

const openCompany = (id: string): void => {
setCompanyId(id);
if (id) setSection('companies');
};
const openContact = (id: string): void => {
setContactId(id);
if (id) setSection('contacts');
};

return (
<div className="flex min-h-0 flex-1 flex-col gap-4 pb-6">
<div>
<h2 className="font-semibold text-[color:var(--ink)] text-lg">CRM</h2>
<p className="mt-0.5 max-w-prose text-[color:var(--muted)] text-sm">
The people and organizations behind the numbers. A contact is only ever
connected to analytics through an active signed consent record — never through
anything stored on the contact itself — so a person with no consent simply has
no link, and this tab says so rather than showing zeroes.
</p>
</div>

{/* The chips above this tab are a filter over analytics dimensions. Nothing here honours
them, and a filtered label over unfiltered numbers is worse than no filter at all. */}
<SegmentNotice tab="crm" />

<div role="tablist" aria-label="CRM sections" className="flex flex-wrap gap-1">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
role="tab"
id={`crm-tab-${s.id}`}
aria-selected={section === s.id}
aria-controls={`crm-panel-${s.id}`}
tabIndex={section === s.id ? 0 : -1}
onKeyDown={(e) => {
if (onSectionKey(e.key, s.id, setSection)) e.preventDefault();
}}
onClick={() => setSection(s.id)}
className={cn(
'rounded-lg border px-3 py-1.5 font-medium text-xs transition',
section === s.id
? 'chip-active'
: 'border-[color:rgb(var(--border))] text-[color:var(--muted)] hover:bg-[color:rgb(var(--hover))] hover:text-[color:var(--ink)]',
)}
>
{s.label}
</button>
))}
</div>

<div
role="tabpanel"
id={`crm-panel-${section}`}
aria-labelledby={`crm-tab-${section}`}
className="min-w-0"
>
{section === 'contacts' ? (
<ContactsPanel
siteId={siteId}
selectedId={contactId}
onSelect={setContactId}
onOpenCompany={openCompany}
/>
) : (
<CompaniesPanel
siteId={siteId}
selectedId={companyId}
onSelect={setCompanyId}
onOpenContact={openContact}
/>
)}
</div>
</div>
);
}
Loading
Loading