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:
186
apps/admin-ui/src/components/SiteCategoriesTab.tsx
Normal file
186
apps/admin-ui/src/components/SiteCategoriesTab.tsx
Normal 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’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’t be disabled.
|
||||
{isInherited && (
|
||||
<> This site is currently <strong>inheriting</strong> its category list from the
|
||||
cascade (group → organisation → 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’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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
138
apps/admin-ui/src/test/SiteCategoriesTab.test.tsx
Normal file
138
apps/admin-ui/src/test/SiteCategoriesTab.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user