Per-site configurable cookie categories (#3)

* feat: per-site configurable cookie categories

Operators can now choose which cookie categories the banner displays
for a given site — useful for sites that genuinely don't use
e.g. marketing cookies and shouldn't be forced to show the toggle.

**Backend**

* New ``enabled_categories`` JSONB column on ``site_configs``,
  ``site_group_configs``, and ``org_configs`` (migration 0003).
  NULL at a level means "inherit"; an explicit list overrides.
* ``config_resolver`` merges ``enabled_categories`` through the
  existing cascade (system → org → group → site) and normalises
  the result via ``_normalise_enabled_categories``:
  - Unknown slugs stripped.
  - ``necessary`` is forced in regardless of the operator's input
    — it's never optional.
  - Empty / invalid values fall back to the full five-category
    default so a cleared field doesn't silently hide the banner.
  - Output is returned in canonical display order so insertion
    order from the cascade doesn't leak into the UI.
* ``build_public_config`` surfaces ``enabled_categories`` to the
  banner-facing public config endpoint.
* Schemas for site/group/org config create + update + response all
  include the new field.

**Banner**

* ``apps/banner/src/banner.ts`` replaces the hard-coded
  ``ALL_CATEGORIES`` / ``NON_ESSENTIAL`` constants with a runtime
  ``resolveEnabledCategories(config)`` helper. ``renderCategories``
  takes the enabled list and only renders toggles for those
  categories; ``nonEssentialFor(enabled)`` derives the user-toggleable
  subset. Falls back to all five when the field is missing in the
  config payload so older banner bundles against newer APIs (and
  vice versa) don't break.
* ``SiteConfig`` type in ``apps/banner/src/types.ts`` has
  ``enabled_categories?: CategorySlug[]`` to match.

**Admin UI**

* New ``SiteCategoriesTab`` component — five checkboxes, ``necessary``
  locked on, with "Reset to inherited" to clear the site override.
  Wired in as a new core tab on ``SiteDetailPage`` between
  Configuration and Cookies.
* ``SiteConfig`` type in ``types/api.ts`` declares ``enabled_categories``
  and a new ``ALL_COOKIE_CATEGORIES`` constant exposing label/description
  metadata shared between the tab component and any future display of
  the list.

**Semantics of a disabled category**

When the operator unticks e.g. ``marketing`` for a site:

* The toggle is not rendered in the banner.
* A visitor can never grant consent for ``marketing``.
* Any cookie or script that classifies into ``marketing`` stays
  blocked permanently by the auto-blocker.

That's the correct behaviour for sites that genuinely don't use a
category: declare it, hide it from the visitor, have the blocker
enforce it.

**Tests**

* ``test_config_resolver.py`` — 13 new cases covering the full
  cascade, ``necessary`` forcing, unknown-slug stripping, empty /
  non-list values, canonical display order, and the public-config
  surface. 37 passed total.
* ``test_SiteCategoriesTab.test.tsx`` — renders all five, locks
  ``necessary``, pre-fills from an override, saves the explicit
  list, and resets to inherited by sending NULL. 6 cases.
* Full API suite (610) and admin-ui suite (139) both green;
  banner bundle builds cleanly with 363 tests passing.

* style: ruff format config_resolver.py
This commit is contained in:
James Cottrill
2026-04-14 14:05:31 +01:00
committed by GitHub
parent 84e41857c3
commit 8d15ec4398
16 changed files with 639 additions and 20 deletions

View File

@@ -0,0 +1,186 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { updateSiteConfig } from '../api/sites';
import { trackConfigChange } from '../services/analytics';
import type { CategorySlug, SiteConfig } from '../types/api';
import { ALL_COOKIE_CATEGORIES } from '../types/api';
import { Alert } from './ui/alert';
import { Button } from './ui/button';
import { Card } from './ui/card';
interface Props {
siteId: string;
config: SiteConfig | null;
}
/**
* Per-site control over which cookie categories the banner displays.
*
* ``necessary`` is always on and can't be disabled. A category that's
* unchecked here is hidden from the banner AND treated as permanently
* unconsented, so any cookie in that category stays blocked. That's
* the correct semantics for a site that genuinely doesn't use e.g.
* marketing cookies: the operator declares it, the visitor never
* sees the toggle, and the auto-blocker enforces it.
*
* ``null`` on the site config means "inherit from the cascade"
* (group → org → system default of all five). The save button
* always writes an explicit list; the "Reset to inherited" button
* clears the override by sending ``null``.
*/
export default function SiteCategoriesTab({ siteId, config }: Props) {
const queryClient = useQueryClient();
const initiallyEnabled = useMemo<Set<CategorySlug>>(() => {
const raw = config?.enabled_categories;
if (!raw || raw.length === 0) {
return new Set(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
}
const known = new Set<CategorySlug>(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
const picked = new Set<CategorySlug>(raw.filter((s): s is CategorySlug => known.has(s)));
picked.add('necessary');
return picked;
}, [config?.enabled_categories]);
const [enabled, setEnabled] = useState<Set<CategorySlug>>(initiallyEnabled);
const [saved, setSaved] = useState(false);
const isInherited = config?.enabled_categories == null;
const mutation = useMutation({
mutationFn: (body: Partial<SiteConfig>) => updateSiteConfig(siteId, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'inheritance'] });
trackConfigChange('site_categories', { site_id: siteId });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const toggle = (slug: CategorySlug, locked: boolean) => {
if (locked) return;
setEnabled((prev) => {
const next = new Set(prev);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
next.add('necessary');
return next;
});
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const payload = ALL_COOKIE_CATEGORIES.map((c) => c.slug).filter((slug) => enabled.has(slug));
mutation.mutate({ enabled_categories: payload });
};
const handleResetToInherited = () => {
mutation.mutate({ enabled_categories: null });
};
const allActive = ALL_COOKIE_CATEGORIES.every((c) => enabled.has(c.slug));
const dirty =
!isInherited &&
(config?.enabled_categories ?? []).slice().sort().join(',') !==
Array.from(enabled).sort().join(',');
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-dashed border-border bg-surface p-4">
<p className="text-xs text-text-secondary">
<strong>Cookie categories.</strong> Untick any category this site doesn&rsquo;t use it
will be hidden from the banner and permanently unconsented, so any cookie that falls
into it stays blocked. <em>Necessary</em> is always on and can&rsquo;t be disabled.
{isInherited && (
<> This site is currently <strong>inheriting</strong> its category list from the
cascade (group &rarr; organisation &rarr; system default).</>
)}
</p>
</div>
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
Categories shown in the banner
</h3>
<div className="space-y-3">
{ALL_COOKIE_CATEGORIES.map((cat) => {
const active = enabled.has(cat.slug);
return (
<label
key={cat.slug}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors ${
active
? 'border-copper bg-copper/5'
: 'border-border bg-transparent hover:bg-surface'
} ${cat.locked ? 'cursor-not-allowed opacity-80' : ''}`}
>
<input
type="checkbox"
className="mt-1"
checked={active}
disabled={cat.locked}
onChange={() => toggle(cat.slug, cat.locked)}
aria-labelledby={`cat-${cat.slug}-label`}
aria-describedby={`cat-${cat.slug}-desc`}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
id={`cat-${cat.slug}-label`}
className="font-heading text-sm font-medium text-foreground"
>
{cat.label}
</span>
{cat.locked && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
Always on
</span>
)}
</div>
<p id={`cat-${cat.slug}-desc`} className="mt-1 text-xs text-text-secondary">
{cat.description}
</p>
</div>
</label>
);
})}
</div>
</Card>
{saved && <Alert variant="success">Categories saved.</Alert>}
{mutation.isError && (
<Alert variant="error">
Couldn&rsquo;t save: {(mutation.error as Error)?.message ?? 'unknown error'}
</Alert>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={mutation.isPending || (!dirty && !isInherited)}>
{mutation.isPending ? 'Saving…' : 'Save categories'}
</Button>
{!isInherited && (
<Button
type="button"
variant="secondary"
onClick={handleResetToInherited}
disabled={mutation.isPending}
>
Reset to inherited
</Button>
)}
{allActive && !isInherited && (
<span className="text-xs text-text-secondary">
All five categories enabled same as the system default.
</span>
)}
</div>
</form>
);
}

View File

@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
import SiteCategoriesTab from '../components/SiteCategoriesTab';
import SiteComplianceTab from '../components/SiteComplianceTab';
import SiteConfigTab from '../components/SiteConfigTab';
import SiteCookiesTab from '../components/SiteCookiesTab';
@@ -16,6 +17,7 @@ import { getSiteDetailTabs } from '../extensions/registry';
const CORE_TABS: { id: string; label: string; order: number }[] = [
{ id: 'overview', label: 'Overview', order: 10 },
{ id: 'config', label: 'Configuration', order: 20 },
{ id: 'categories', label: 'Categories', order: 25 },
{ id: 'cookies', label: 'Cookies', order: 30 },
{ id: 'banner', label: 'Banner', order: 40 },
{ id: 'translations', label: 'Translations', order: 50 },
@@ -89,6 +91,9 @@ export default function SiteDetailPage() {
{/* Tab content — core tabs */}
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
{activeTab === 'categories' && siteId && (
<SiteCategoriesTab siteId={siteId} config={config ?? null} />
)}
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
{activeTab === 'banner' && siteId && (
<BannerBuilderTab

View File

@@ -0,0 +1,138 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SiteCategoriesTab from '../components/SiteCategoriesTab';
import type { SiteConfig } from '../types/api';
vi.mock('../api/sites', () => ({
updateSiteConfig: vi.fn().mockResolvedValue({}),
}));
vi.mock('../services/analytics', () => ({
trackConfigChange: vi.fn(),
}));
import { updateSiteConfig } from '../api/sites';
function renderWithProviders(ui: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const BASE_CONFIG: SiteConfig = {
id: 'cfg-1',
site_id: 'site-1',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: true,
gpp_supported_apis: ['usnat'],
gpc_enabled: true,
gpc_jurisdictions: null,
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
scan_enabled: true,
scan_frequency_hours: 168,
scan_max_pages: 50,
enabled_categories: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
describe('SiteCategoriesTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all five categories with necessary locked', () => {
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Functional/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Marketing/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).toBeInTheDocument();
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
expect(necessary).toBeChecked();
expect(necessary).toBeDisabled();
});
it('shows "inheriting" copy when config has no override', () => {
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
expect(screen.getByText(/inheriting/i)).toBeInTheDocument();
});
it('pre-fills from existing override', () => {
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
/>,
);
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Functional/i })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Marketing/i })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).not.toBeChecked();
});
it('saves an explicit category list on submit', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics', 'marketing'] }}
/>,
);
// Drop marketing
await user.click(screen.getByRole('checkbox', { name: /Marketing/i }));
await user.click(screen.getByRole('button', { name: /Save categories/i }));
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
enabled_categories: ['necessary', 'analytics'],
});
});
it('refuses to unlock necessary', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
/>,
);
// Clicking the locked checkbox is a no-op
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
await user.click(necessary);
expect(necessary).toBeChecked();
});
it('resets to inherited by sending null', async () => {
const user = userEvent.setup();
renderWithProviders(
<SiteCategoriesTab
siteId="site-1"
config={{ ...BASE_CONFIG, enabled_categories: ['necessary'] }}
/>,
);
await user.click(screen.getByRole('button', { name: /Reset to inherited/i }));
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
enabled_categories: null,
});
});
});

View File

@@ -41,6 +41,7 @@ const BASE_CONFIG: SiteConfig = {
scan_enabled: true,
scan_frequency_hours: 168,
scan_max_pages: 50,
enabled_categories: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};

View File

@@ -129,10 +129,62 @@ export interface SiteConfig {
scan_enabled: boolean;
scan_frequency_hours: number;
scan_max_pages: number;
/**
* Cookie categories the banner should display. ``null`` means
* "inherit from the cascade" (group → org → system default of all
* five). An explicit list overrides; ``necessary`` is always
* implicit and re-added by the resolver if missing.
*/
enabled_categories: CategorySlug[] | null;
created_at: string;
updated_at: string;
}
export type CategorySlug =
| 'necessary'
| 'functional'
| 'analytics'
| 'marketing'
| 'personalisation';
export const ALL_COOKIE_CATEGORIES: {
slug: CategorySlug;
label: string;
description: string;
locked: boolean;
}[] = [
{
slug: 'necessary',
label: 'Necessary',
description: 'Essential for the website to function. Always active and cannot be disabled.',
locked: true,
},
{
slug: 'functional',
label: 'Functional',
description: 'Remember preferences and enable enhanced features (e.g. language, chat widgets).',
locked: false,
},
{
slug: 'analytics',
label: 'Analytics',
description: 'Measure traffic and interaction so you can understand how visitors use the site.',
locked: false,
},
{
slug: 'marketing',
label: 'Marketing',
description: 'Advertising, remarketing, and cross-site tracking.',
locked: false,
},
{
slug: 'personalisation',
label: 'Personalisation',
description: 'Tailor content, recommendations, and the banner appearance to the visitor.',
locked: false,
},
];
export interface ButtonConfig {
backgroundColour?: string;
textColour?: string;