From 8d15ec439804966137af5b30d41c98a3ae1794fb Mon Sep 17 00:00:00 2001 From: James Cottrill <32595786+jamescottrill@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:05:31 +0100 Subject: [PATCH] Per-site configurable cookie categories (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../src/components/SiteCategoriesTab.tsx | 186 ++++++++++++++++++ apps/admin-ui/src/pages/SiteDetailPage.tsx | 5 + .../src/test/SiteCategoriesTab.test.tsx | 138 +++++++++++++ apps/admin-ui/src/test/SiteConfigTab.test.tsx | 1 + apps/admin-ui/src/types/api.ts | 52 +++++ .../versions/0003_enabled_categories.py | 46 +++++ apps/api/src/models/org_config.py | 5 + apps/api/src/models/site_config.py | 7 + apps/api/src/models/site_group_config.py | 5 + apps/api/src/schemas/org_config.py | 2 + apps/api/src/schemas/site.py | 6 + apps/api/src/schemas/site_group_config.py | 2 + apps/api/src/services/config_resolver.py | 49 +++++ apps/api/tests/test_config_resolver.py | 71 +++++++ apps/banner/src/banner.ts | 76 +++++-- apps/banner/src/types.ts | 8 + 16 files changed, 639 insertions(+), 20 deletions(-) create mode 100644 apps/admin-ui/src/components/SiteCategoriesTab.tsx create mode 100644 apps/admin-ui/src/test/SiteCategoriesTab.test.tsx create mode 100644 apps/api/alembic/versions/0003_enabled_categories.py diff --git a/apps/admin-ui/src/components/SiteCategoriesTab.tsx b/apps/admin-ui/src/components/SiteCategoriesTab.tsx new file mode 100644 index 0000000..1133599 --- /dev/null +++ b/apps/admin-ui/src/components/SiteCategoriesTab.tsx @@ -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>(() => { + const raw = config?.enabled_categories; + if (!raw || raw.length === 0) { + return new Set(ALL_COOKIE_CATEGORIES.map((c) => c.slug)); + } + const known = new Set(ALL_COOKIE_CATEGORIES.map((c) => c.slug)); + const picked = new Set(raw.filter((s): s is CategorySlug => known.has(s))); + picked.add('necessary'); + return picked; + }, [config?.enabled_categories]); + + const [enabled, setEnabled] = useState>(initiallyEnabled); + const [saved, setSaved] = useState(false); + + const isInherited = config?.enabled_categories == null; + + const mutation = useMutation({ + mutationFn: (body: Partial) => 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 ( +
+
+

+ Cookie categories. 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. Necessary is always on and can’t be disabled. + {isInherited && ( + <> This site is currently inheriting its category list from the + cascade (group → organisation → system default). + )} +

+
+ + +

+ Categories shown in the banner +

+ +
+ {ALL_COOKIE_CATEGORIES.map((cat) => { + const active = enabled.has(cat.slug); + return ( + + ); + })} +
+
+ + {saved && Categories saved.} + {mutation.isError && ( + + Couldn’t save: {(mutation.error as Error)?.message ?? 'unknown error'} + + )} + +
+ + {!isInherited && ( + + )} + {allActive && !isInherited && ( + + All five categories enabled — same as the system default. + + )} +
+
+ ); +} diff --git a/apps/admin-ui/src/pages/SiteDetailPage.tsx b/apps/admin-ui/src/pages/SiteDetailPage.tsx index 9d101ee..fff3641 100644 --- a/apps/admin-ui/src/pages/SiteDetailPage.tsx +++ b/apps/admin-ui/src/pages/SiteDetailPage.tsx @@ -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' && } {activeTab === 'config' && siteId && } + {activeTab === 'categories' && siteId && ( + + )} {activeTab === 'cookies' && siteId && } {activeTab === 'banner' && siteId && ( ({ + 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({ui}); +} + +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(); + + 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(); + expect(screen.getByText(/inheriting/i)).toBeInTheDocument(); + }); + + it('pre-fills from existing override', () => { + renderWithProviders( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + await user.click(screen.getByRole('button', { name: /Reset to inherited/i })); + + expect(updateSiteConfig).toHaveBeenCalledWith('site-1', { + enabled_categories: null, + }); + }); +}); diff --git a/apps/admin-ui/src/test/SiteConfigTab.test.tsx b/apps/admin-ui/src/test/SiteConfigTab.test.tsx index 2afd3a8..fba00d2 100644 --- a/apps/admin-ui/src/test/SiteConfigTab.test.tsx +++ b/apps/admin-ui/src/test/SiteConfigTab.test.tsx @@ -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', }; diff --git a/apps/admin-ui/src/types/api.ts b/apps/admin-ui/src/types/api.ts index a7c9734..d0636b3 100644 --- a/apps/admin-ui/src/types/api.ts +++ b/apps/admin-ui/src/types/api.ts @@ -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; diff --git a/apps/api/alembic/versions/0003_enabled_categories.py b/apps/api/alembic/versions/0003_enabled_categories.py new file mode 100644 index 0000000..23d0952 --- /dev/null +++ b/apps/api/alembic/versions/0003_enabled_categories.py @@ -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") diff --git a/apps/api/src/models/org_config.py b/apps/api/src/models/org_config.py index 59ea39e..d3eb2da 100644 --- a/apps/api/src/models/org_config.py +++ b/apps/api/src/models/org_config.py @@ -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) diff --git a/apps/api/src/models/site_config.py b/apps/api/src/models/site_config.py index 24c82dd..3e1261b 100644 --- a/apps/api/src/models/site_config.py +++ b/apps/api/src/models/site_config.py @@ -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) diff --git a/apps/api/src/models/site_group_config.py b/apps/api/src/models/site_group_config.py index 6e9e8f8..9e53ca7 100644 --- a/apps/api/src/models/site_group_config.py +++ b/apps/api/src/models/site_group_config.py @@ -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) diff --git a/apps/api/src/schemas/org_config.py b/apps/api/src/schemas/org_config.py index da46fa3..9f5e244 100644 --- a/apps/api/src/schemas/org_config.py +++ b/apps/api/src/schemas/org_config.py @@ -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 diff --git a/apps/api/src/schemas/site.py b/apps/api/src/schemas/site.py index fde4a4f..398600f 100644 --- a/apps/api/src/schemas/site.py +++ b/apps/api/src/schemas/site.py @@ -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 diff --git a/apps/api/src/schemas/site_group_config.py b/apps/api/src/schemas/site_group_config.py index 2219922..10f84c6 100644 --- a/apps/api/src/schemas/site_group_config.py +++ b/apps/api/src/schemas/site_group_config.py @@ -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 diff --git a/apps/api/src/services/config_resolver.py b/apps/api/src/services/config_resolver.py index e198124..add8180 100644 --- a/apps/api/src/services/config_resolver.py +++ b/apps/api/src/services/config_resolver.py @@ -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", ) diff --git a/apps/api/tests/test_config_resolver.py b/apps/api/tests/test_config_resolver.py index 5adedf0..62c7df3 100644 --- a/apps/api/tests/test_config_resolver.py +++ b/apps/api/tests/test_config_resolver.py @@ -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 diff --git a/apps/banner/src/banner.ts b/apps/banner/src/banner.ts index a13ed87..a0878a3 100644 --- a/apps/banner/src/banner.ts +++ b/apps/banner/src/banner.ts @@ -89,6 +89,12 @@ export function registerEEHooks(hooks: Partial): 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( + 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 { @@ -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 = `
- ${renderCategories(t)} + ${renderCategories(t, enabledCategories)}