feat: consent records page, tab persistence, and snippet copy fix (#9)

feat: consent records list endpoint and top-level admin page
This commit is contained in:
James Cottrill
2026-04-18 21:22:06 +01:00
committed by GitHub
parent bebcf901f4
commit 142e2373d3
10 changed files with 442 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ import {
import Layout from './components/Layout';
import { trackPageView } from './services/analytics';
import ProtectedRoute from './components/ProtectedRoute';
import ConsentRecordsPage from './pages/ConsentRecordsPage';
import LoginPage from './pages/LoginPage';
import SettingsPage from './pages/SettingsPage';
import SiteDetailPage from './pages/SiteDetailPage';
@@ -57,6 +58,7 @@ function AppRoutes() {
<Route path="/sites" element={<SitesPage />} />
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
<Route path="/consent" element={<ConsentRecordsPage />} />
<Route path="/settings" element={<SettingsPage />} />
{extensionPages
.filter((p) => p.protected !== false)

View File

@@ -0,0 +1,12 @@
import type { ConsentRecord, PaginatedResponse } from '../types/api';
import apiClient from './client';
export async function listConsentRecords(
siteId: string,
params?: { visitor_id?: string; page?: number; page_size?: number },
): Promise<PaginatedResponse<ConsentRecord>> {
const { data } = await apiClient.get<PaginatedResponse<ConsentRecord>>('/consent/', {
params: { site_id: siteId, ...params },
});
return data;
}

View File

@@ -6,6 +6,7 @@ import { getNavItems } from '../extensions/registry';
const CORE_NAV_ITEMS = [
{ path: '/sites', label: 'Sites', order: 10 },
{ path: '/consent', label: 'Consent Records', order: 15 },
{ path: '/settings', label: 'Settings', order: 90 },
];

View File

@@ -0,0 +1,235 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listConsentRecords } from '../api/consent';
import type { ConsentRecord } from '../types/api';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { LoadingState } from './ui/loading-state';
interface Props {
siteId: string;
}
function actionVariant(action: string): 'success' | 'error' | 'warning' | 'neutral' {
const map: Record<string, 'success' | 'error' | 'warning'> = {
accept_all: 'success',
reject_all: 'error',
custom: 'warning',
withdraw: 'error',
};
return map[action] ?? 'neutral';
}
function actionLabel(action: string): string {
const map: Record<string, string> = {
accept_all: 'Accept all',
reject_all: 'Reject all',
custom: 'Custom',
withdraw: 'Withdrawn',
};
return map[action] ?? action;
}
function RecordDetail({ record }: { record: ConsentRecord }) {
return (
<tr>
<td colSpan={5} className="bg-mist px-4 py-3">
<div className="grid gap-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="font-medium text-text-secondary">Visitor ID</span>
<p className="mt-0.5 break-all font-mono">{record.visitor_id}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Page URL</span>
<p className="mt-0.5 break-all">{record.page_url ?? '—'}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Accepted</span>
<p className="mt-0.5">{record.categories_accepted.join(', ') || '—'}</p>
</div>
<div>
<span className="font-medium text-text-secondary">Rejected</span>
<p className="mt-0.5">{record.categories_rejected?.join(', ') || '—'}</p>
</div>
{record.country_code && (
<div>
<span className="font-medium text-text-secondary">Location</span>
<p className="mt-0.5">{record.region_code ? `${record.country_code}-${record.region_code}` : record.country_code}</p>
</div>
)}
{record.tc_string && (
<div>
<span className="font-medium text-text-secondary">TC String</span>
<p className="mt-0.5 break-all font-mono text-[11px]">{record.tc_string}</p>
</div>
)}
{record.gpc_detected != null && (
<div>
<span className="font-medium text-text-secondary">GPC</span>
<p className="mt-0.5">
Detected: {record.gpc_detected ? 'Yes' : 'No'}
{record.gpc_honoured != null && ` · Honoured: ${record.gpc_honoured ? 'Yes' : 'No'}`}
</p>
</div>
)}
</div>
</td>
</tr>
);
}
export default function SiteConsentTab({ siteId }: Props) {
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [page, setPage] = useState(1);
const [expandedId, setExpandedId] = useState<string | null>(null);
const pageSize = 25;
const { data, isLoading } = useQuery({
queryKey: ['consent', siteId, activeSearch, page],
queryFn: () =>
listConsentRecords(siteId, {
visitor_id: activeSearch || undefined,
page,
page_size: pageSize,
}),
});
const handleSearch = () => {
setActiveSearch(search.trim());
setPage(1);
};
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
return (
<div>
{/* Search */}
<Card className="mb-6 p-5">
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">
Search Consent Records
</h3>
<div className="flex flex-wrap gap-3">
<input
type="text"
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
placeholder="Search by visitor ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch}>Search</Button>
{activeSearch && (
<Button
variant="secondary"
onClick={() => {
setSearch('');
setActiveSearch('');
setPage(1);
}}
>
Clear
</Button>
)}
</div>
{activeSearch && (
<p className="mt-2 text-xs text-text-secondary">
Showing results for visitor: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{activeSearch}</code>
</p>
)}
</Card>
{/* Results */}
{isLoading ? (
<LoadingState message="Loading consent records..." />
) : !data || data.items.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
{activeSearch
? 'No consent records found for this visitor.'
: 'No consent records yet.'}
</div>
) : (
<>
<div className="mb-3 flex items-center justify-between text-xs text-text-secondary">
<span>{data.total} record{data.total !== 1 ? 's' : ''}</span>
<span>Page {page} of {totalPages}</span>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Visitor</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Action</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Categories</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Date</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.items.map((record) => (
<>
<tr
key={record.id}
className="cursor-pointer hover:bg-mist"
onClick={() => setExpandedId(expandedId === record.id ? null : record.id)}
>
<td className="px-4 py-3 font-mono text-xs">
{record.visitor_id.length > 16
? record.visitor_id.slice(0, 8) + '…' + record.visitor_id.slice(-8)
: record.visitor_id}
</td>
<td className="px-4 py-3">
<Badge variant={actionVariant(record.action)}>
{actionLabel(record.action)}
</Badge>
</td>
<td className="px-4 py-3 text-text-secondary">
{record.categories_accepted.join(', ')}
</td>
<td className="px-4 py-3 text-text-secondary">
{new Date(record.consented_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-text-tertiary">
{expandedId === record.id ? '▲' : '▼'}
</td>
</tr>
{expandedId === record.id && (
<RecordDetail key={`${record.id}-detail`} record={record} />
)}
</>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span className="text-xs text-text-secondary">
{page} / {totalPages}
</span>
<Button
variant="secondary"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { MetricCard } from './ui/metric-card';
import type { Site, SiteConfig } from '../types/api';
@@ -8,7 +10,8 @@ interface Props {
}
export default function SiteOverviewTab({ site, config }: Props) {
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}" async></script>`;
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}"></script>`;
const [copied, setCopied] = useState(false);
return (
<div className="space-y-6">
@@ -36,17 +39,38 @@ export default function SiteOverviewTab({ site, config }: Props) {
<p className="mb-3 text-sm text-text-secondary">
Add this script tag to the {'<head>'} of your website, before any other scripts.
</p>
<div className="relative">
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
{scriptTag}
</pre>
<div className="flex items-stretch">
<input
type="text"
readOnly
value={scriptTag}
className="block w-full min-w-0 rounded-l-lg border border-r-0 border-border bg-mist px-3 py-2.5 font-mono text-xs text-foreground focus:outline-none"
/>
<button
onClick={() => navigator.clipboard.writeText(scriptTag)}
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
type="button"
onClick={() => {
navigator.clipboard.writeText(scriptTag).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="inline-flex shrink-0 items-center gap-2 rounded-r-lg border border-copper bg-copper px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-copper/90 focus:outline-none focus:ring-2 focus:ring-copper/50"
>
Copy
{copied ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-3 5h3m-6 0h.01M12 16h3m-6 0h.01M10 3v4h4V3h-4Z" />
</svg>
)}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<p className="mt-2 text-xs text-text-secondary">
Must be the first {'<script>'} in {'<head>'} no <code>async</code> or <code>defer</code>.
</p>
</Card>
{/* Features */}

View File

@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listSites } from '../api/sites';
import SiteConsentTab from '../components/SiteConsentTab';
import { Select } from '../components/ui/select';
import type { Site } from '../types/api';
export default function ConsentRecordsPage() {
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
queryKey: ['sites'],
queryFn: listSites,
});
return (
<div>
<div className="mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
Consent Records
</h1>
<p className="mt-1 text-sm text-text-secondary">
View and search consent records across your sites.
</p>
</div>
<div className="mb-6 max-w-xs">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Site
</label>
<Select
value={selectedSiteId}
onChange={(e) => setSelectedSiteId(e.target.value)}
disabled={sitesLoading}
>
<option value="">Select a site</option>
{sites?.map((site) => (
<option key={site.id} value={site.id}>
{site.display_name ?? site.domain}
</option>
))}
</Select>
</div>
{selectedSiteId ? (
<SiteConsentTab siteId={selectedSiteId} />
) : (
<div className="py-12 text-center text-sm text-text-secondary">
Select a site to view its consent records.
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
import SiteCategoriesTab from '../components/SiteCategoriesTab';
@@ -25,7 +25,15 @@ const CORE_TABS: { id: string; label: string; order: number }[] = [
export default function SiteDetailPage() {
const { siteId } = useParams<{ siteId: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const location = useLocation();
const navigate = useNavigate();
// Persist the active tab in the URL hash so a page refresh restores it.
const activeTab = location.hash.replace('#', '') || 'overview';
const setActiveTab = useCallback(
(tab: string) => navigate({ hash: tab }, { replace: true }),
[navigate],
);
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
const allTabs = useMemo(() => {

View File

@@ -726,3 +726,28 @@ export interface ConsentReceiptResponse {
banner_version_hash: string | null;
created_at: string;
}
export interface ConsentRecord {
id: string;
site_id: string;
visitor_id: string;
action: string;
categories_accepted: string[];
categories_rejected: string[] | null;
tc_string: string | null;
gcm_state: Record<string, string> | null;
gpp_string: string | null;
gpc_detected: boolean | null;
gpc_honoured: boolean | null;
page_url: string | null;
country_code: string | null;
region_code: string | null;
consented_at: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
}

View File

@@ -1,7 +1,7 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import get_db
@@ -11,6 +11,7 @@ from src.models.site import Site
from src.schemas.auth import CurrentUser
from src.schemas.consent import (
ConsentRecordCreate,
ConsentRecordListResponse,
ConsentRecordResponse,
ConsentVerifyResponse,
)
@@ -86,6 +87,63 @@ async def _load_record_for_org(
return record
@router.get("/", response_model=ConsentRecordListResponse)
async def list_consent_records(
site_id: uuid.UUID = Query(..., description="Filter by site"),
visitor_id: str | None = Query(None, description="Filter by visitor ID (exact match)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
db: AsyncSession = Depends(get_db),
) -> dict:
"""List consent records for a site, with optional visitor_id filter.
Tenant-isolated — the site must belong to the caller's organisation.
Returns newest records first.
"""
# Verify site belongs to the caller's org.
site = (
await db.execute(
select(Site).where(
Site.id == site_id,
Site.organisation_id == current_user.organisation_id,
Site.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if site is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
base = select(ConsentRecord).where(ConsentRecord.site_id == site_id)
count_base = (
select(func.count()).select_from(ConsentRecord).where(ConsentRecord.site_id == site_id)
)
if visitor_id:
base = base.where(ConsentRecord.visitor_id == visitor_id)
count_base = count_base.where(ConsentRecord.visitor_id == visitor_id)
total = await db.scalar(count_base) or 0
items = (
(
await db.execute(
base.order_by(ConsentRecord.consented_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
.scalars()
.all()
)
return {
"items": list(items),
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
async def get_consent(
consent_id: uuid.UUID,

View File

@@ -50,6 +50,15 @@ class ConsentRecordResponse(BaseModel):
model_config = {"from_attributes": True}
class ConsentRecordListResponse(BaseModel):
"""Paginated list of consent records."""
items: list[ConsentRecordResponse]
total: int
page: int
page_size: int
class ConsentVerifyResponse(BaseModel):
"""Audit proof that a consent record exists."""