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;

View File

@@ -0,0 +1,46 @@
"""enabled_categories on site / group / org configs
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-14
Per-site control over which cookie categories the banner displays.
Cascades the same way every other config setting does — site overrides
site-group overrides org overrides system default (all 5 categories).
Stored as JSONB rather than an array column so the resolver sees a
plain Python list via SQLAlchemy's JSONB codec and doesn't need
PostgreSQL-specific array handling in the merge logic.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0003"
down_revision: str | Sequence[str] | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLES = ("site_configs", "site_group_configs", "org_configs")
def upgrade() -> None:
for table in _TABLES:
op.add_column(
table,
sa.Column(
"enabled_categories",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
def downgrade() -> None:
for table in _TABLES:
op.drop_column(table, "enabled_categories")

View File

@@ -52,6 +52,11 @@ class OrgConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. NULL = inherit (system
# default is all five). See ``SiteConfig.enabled_categories`` for
# the full cascade semantics.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -51,6 +51,13 @@ class SiteConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. When NULL, inherit from the
# cascade (site-group → org → system default of all five). An explicit
# list overrides. ``necessary`` is always implicit and will be forced
# back into the merged result by the resolver, so operators can't
# accidentally drop it.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int] = mapped_column(Integer, server_default="50", nullable=False)

View File

@@ -52,6 +52,11 @@ class SiteGroupConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Cookie categories shown in the banner. NULL = inherit (system
# default is all five). See ``SiteConfig.enabled_categories`` for
# the full cascade semantics.
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Scanning
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -31,6 +31,7 @@ class OrgConfigUpdate(BaseModel):
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class OrgConfigResponse(BaseModel):
@@ -55,6 +56,7 @@ class OrgConfigResponse(BaseModel):
scan_max_pages: int | None
consent_expiry_days: int | None
consent_retention_days: int | None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -65,6 +65,10 @@ class SiteConfigCreate(BaseModel):
scan_max_pages: int = Field(default=50, ge=1, le=1000)
consent_expiry_days: int = Field(default=365, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
# None = inherit from the cascade (group → org → system). An
# explicit list overrides; the resolver re-adds ``necessary``
# if omitted and drops any unknown slugs.
enabled_categories: list[str] | None = None
class SiteConfigUpdate(BaseModel):
@@ -87,6 +91,7 @@ class SiteConfigUpdate(BaseModel):
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class SiteConfigResponse(BaseModel):
@@ -111,6 +116,7 @@ class SiteConfigResponse(BaseModel):
scan_max_pages: int = 50
consent_expiry_days: int = 365
consent_retention_days: int | None = None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -30,6 +30,7 @@ class SiteGroupConfigUpdate(BaseModel):
scan_schedule_cron: str | None = None
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
enabled_categories: list[str] | None = None
class SiteGroupConfigResponse(BaseModel):
@@ -53,6 +54,7 @@ class SiteGroupConfigResponse(BaseModel):
scan_schedule_cron: str | None
scan_max_pages: int | None
consent_expiry_days: int | None
enabled_categories: list[str] | None = None
created_at: datetime
updated_at: datetime

View File

@@ -10,6 +10,23 @@ from __future__ import annotations
from typing import Any
# Every known cookie category, in the canonical display order the
# banner uses. The system default for ``enabled_categories`` is this
# full list; operators subset from the top via the cascade.
ALL_CATEGORIES: list[str] = [
"necessary",
"functional",
"analytics",
"marketing",
"personalisation",
]
# ``necessary`` is never optional — operators can't hide it and the
# merged result always contains it, even if it's been accidentally
# dropped from every layer of the cascade.
REQUIRED_CATEGORIES: frozenset[str] = frozenset({"necessary"})
# System-level defaults (hard-coded, lowest priority)
SYSTEM_DEFAULTS: dict[str, Any] = {
"blocking_mode": "opt_in",
@@ -34,6 +51,10 @@ SYSTEM_DEFAULTS: dict[str, Any] = {
"privacy_policy_url": None,
"terms_url": None,
"consent_expiry_days": 365,
# All five categories visible by default; any cascade layer may
# narrow this to a subset. The resolver normalises the result
# via ``_normalise_enabled_categories``.
"enabled_categories": ALL_CATEGORIES,
}
@@ -77,9 +98,33 @@ def resolve_config(
if regional_mode:
resolved["blocking_mode"] = regional_mode
resolved["enabled_categories"] = _normalise_enabled_categories(
resolved.get("enabled_categories")
)
return resolved
def _normalise_enabled_categories(value: Any) -> list[str]:
"""Clean a merged ``enabled_categories`` value into a canonical list.
- ``None`` / empty / invalid types fall back to the full default.
- Unknown slugs are stripped so a typo can't light up a category
the banner doesn't actually render.
- ``necessary`` is always forced into the output — required
categories can never be absent, regardless of what the operator
configured. The order mirrors ``ALL_CATEGORIES`` so the banner
renders tabs in a consistent order no matter the insertion order.
"""
if not isinstance(value, list) or not value:
return list(ALL_CATEGORIES)
known = set(ALL_CATEGORIES)
picked = {slug for slug in value if isinstance(slug, str) and slug in known}
picked.update(REQUIRED_CATEGORIES)
return [slug for slug in ALL_CATEGORIES if slug in picked]
def build_public_config(
site_id: str,
resolved: dict[str, Any],
@@ -108,6 +153,9 @@ def build_public_config(
"consent_expiry_days": resolved["consent_expiry_days"],
"consent_group_id": resolved.get("consent_group_id"),
"ab_test": resolved.get("ab_test"),
# Public name is ``enabled_categories`` here; the banner schema
# converts that to ``enabledCategories`` when it serialises.
"enabled_categories": _normalise_enabled_categories(resolved.get("enabled_categories")),
}
@@ -128,6 +176,7 @@ CONFIG_FIELDS = (
"privacy_policy_url",
"terms_url",
"consent_expiry_days",
"enabled_categories",
)

View File

@@ -5,7 +5,9 @@ import uuid
import pytest
from src.services.config_resolver import (
ALL_CATEGORIES,
SYSTEM_DEFAULTS,
_normalise_enabled_categories,
build_public_config,
resolve_config,
)
@@ -256,3 +258,72 @@ class TestConfigRoutes:
site_id = uuid.uuid4()
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
assert resp.status_code == 401
class TestEnabledCategories:
"""Cascade semantics for ``enabled_categories``."""
def test_system_default_is_all_five(self):
result = resolve_config({})
assert result["enabled_categories"] == ALL_CATEGORIES
def test_site_override_narrows_system_default(self):
result = resolve_config({"enabled_categories": ["necessary", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics"]
def test_site_override_beats_org_override(self):
result = resolve_config(
site_config={"enabled_categories": ["necessary", "marketing"]},
org_defaults={"enabled_categories": ["necessary", "analytics"]},
)
assert result["enabled_categories"] == ["necessary", "marketing"]
def test_group_override_beats_org_override_when_site_unset(self):
result = resolve_config(
site_config={},
org_defaults={"enabled_categories": ["necessary", "analytics"]},
group_defaults={"enabled_categories": ["necessary", "functional"]},
)
assert result["enabled_categories"] == ["necessary", "functional"]
def test_unset_site_inherits_org(self):
result = resolve_config(
site_config={},
org_defaults={"enabled_categories": ["necessary", "marketing"]},
)
assert result["enabled_categories"] == ["necessary", "marketing"]
def test_necessary_forced_in_when_missing_from_override(self):
"""Operators can't accidentally drop ``necessary``."""
result = resolve_config({"enabled_categories": ["analytics", "marketing"]})
assert "necessary" in result["enabled_categories"]
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
def test_unknown_slugs_are_stripped(self):
result = resolve_config({"enabled_categories": ["necessary", "spam", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics"]
def test_empty_list_falls_back_to_default(self):
"""An empty list is treated as 'no categories configured' → default."""
result = resolve_config({"enabled_categories": []})
assert result["enabled_categories"] == ALL_CATEGORIES
def test_non_list_value_falls_back_to_default(self):
result = resolve_config({"enabled_categories": "not-a-list"}) # type: ignore[dict-item]
assert result["enabled_categories"] == ALL_CATEGORIES
def test_result_is_in_canonical_display_order(self):
"""Insertion order from the cascade must not leak into the output."""
result = resolve_config({"enabled_categories": ["marketing", "necessary", "analytics"]})
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
def test_public_config_includes_enabled_categories(self):
resolved = resolve_config({"enabled_categories": ["necessary", "analytics"]})
public = build_public_config("site-xyz", resolved)
assert public["enabled_categories"] == ["necessary", "analytics"]
def test_normalise_handles_none(self):
assert _normalise_enabled_categories(None) == ALL_CATEGORIES
def test_normalise_preserves_explicit_full_list(self):
assert _normalise_enabled_categories(list(ALL_CATEGORIES)) == ALL_CATEGORIES

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. */