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:
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user