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

@@ -89,6 +89,12 @@ export function registerEEHooks(hooks: Partial<EEHooks>): void {
// Expose for EE bundle
(window as any).__consentos_register_ee = registerEEHooks;
/**
* Every known category, in canonical display order. Used as the
* fallback when ``SiteConfig.enabled_categories`` isn't present in
* the API response (older deployments) and as the reference order
* for deduping / sorting runtime subsets.
*/
const ALL_CATEGORIES: CategorySlug[] = [
'necessary',
'functional',
@@ -97,12 +103,33 @@ const ALL_CATEGORIES: CategorySlug[] = [
'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional',
'analytics',
'marketing',
'personalisation',
];
/**
* Return the categories the banner should render for this config.
* ``necessary`` is always implicit and forced back in if missing;
* unknown slugs are filtered; the result is sorted into the canonical
* display order so toggle positions don't jump around based on the
* cascade's insertion order. When the field is absent we return the
* full five — matches legacy behaviour and keeps older banner
* bundles working against an older API.
*/
function resolveEnabledCategories(config: SiteConfig): CategorySlug[] {
const raw = config.enabled_categories;
if (!raw || !Array.isArray(raw) || raw.length === 0) {
return [...ALL_CATEGORIES];
}
const picked = new Set<CategorySlug>(
raw.filter((slug): slug is CategorySlug =>
(ALL_CATEGORIES as string[]).includes(slug as string),
),
);
picked.add('necessary');
return ALL_CATEGORIES.filter((slug) => picked.has(slug));
}
/** Categories the user can toggle — everything except ``necessary``. */
function nonEssentialFor(enabled: CategorySlug[]): CategorySlug[] {
return enabled.filter((slug) => slug !== 'necessary');
}
/** Initialise the banner. Called when the bundle loads. */
async function init(): Promise<void> {
@@ -240,6 +267,7 @@ function buildDefaultConfig(siteId: string): SiteConfig {
consent_group_id: null,
ab_test: null,
initiator_map: null,
enabled_categories: [...ALL_CATEGORIES],
};
}
@@ -266,6 +294,9 @@ function renderBanner(
const titleId = 'cmp-title';
const descId = 'cmp-desc';
const enabledCategories = resolveEnabledCategories(config);
const nonEssential = nonEssentialFor(enabledCategories);
shadow.innerHTML = `
<style>${getBannerStyles(config)}</style>
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
@@ -277,7 +308,7 @@ function renderBanner(
</p>
</div>
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
${renderCategories(t)}
${renderCategories(t, enabledCategories)}
</div>
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
@@ -321,7 +352,7 @@ function renderBanner(
// Set up keyboard navigation
const cleanupFocusTrap = trapFocus(banner);
const cleanupEscape = onEscape(banner, () => {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
});
@@ -329,11 +360,12 @@ function renderBanner(
btn.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
if (action === 'accept') {
// Explicit Accept All overrides GPC — user choice takes precedence
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
// Explicit Accept All overrides GPC — user choice takes precedence.
// "All" only includes the categories the operator has enabled.
handleConsent([...enabledCategories], [], config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'reject') {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'settings') {
const isHidden = categoriesDiv.style.display === 'none';
@@ -342,7 +374,7 @@ function renderBanner(
announce(liveRegion, isHidden ? t.managePreferences : t.title);
} else if (action === 'save') {
const accepted = getSelectedCategories(shadow);
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
const rejected = nonEssential.filter((c) => !accepted.includes(c));
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
}
@@ -355,16 +387,20 @@ function renderBanner(
focusFirst(banner);
}
/** Render category toggles HTML. */
function renderCategories(t: TranslationStrings): string {
const categories = [
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
/** Render category toggles HTML. Only renders the categories the
* config has enabled — ``necessary`` is always present and locked. */
function renderCategories(t: TranslationStrings, enabled: CategorySlug[]): string {
const all = [
{ slug: 'necessary' as const, name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional' as const, name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics' as const, name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing' as const, name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation' as const, name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
];
const enabledSet = new Set(enabled);
const categories = all.filter((cat) => enabledSet.has(cat.slug));
return (
categories
.map(

View File

@@ -89,6 +89,14 @@ export interface SiteConfig {
ab_test: ABTestConfig | null;
/** Initiator map: root script URL → category for root-level blocking. */
initiator_map: InitiatorMapping[] | null;
/**
* Cookie categories the banner should render. Always contains
* ``necessary``; operators subset the remaining four via the config
* cascade (site → group → org → system default of all five). Older
* API responses may omit this field — callers should fall back to
* every known category in that case.
*/
enabled_categories?: CategorySlug[];
}
/** Maps a root initiator script to the cookie category it ultimately sets. */