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 { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
||||||
|
import SiteCategoriesTab from '../components/SiteCategoriesTab';
|
||||||
import SiteComplianceTab from '../components/SiteComplianceTab';
|
import SiteComplianceTab from '../components/SiteComplianceTab';
|
||||||
import SiteConfigTab from '../components/SiteConfigTab';
|
import SiteConfigTab from '../components/SiteConfigTab';
|
||||||
import SiteCookiesTab from '../components/SiteCookiesTab';
|
import SiteCookiesTab from '../components/SiteCookiesTab';
|
||||||
@@ -16,6 +17,7 @@ import { getSiteDetailTabs } from '../extensions/registry';
|
|||||||
const CORE_TABS: { id: string; label: string; order: number }[] = [
|
const CORE_TABS: { id: string; label: string; order: number }[] = [
|
||||||
{ id: 'overview', label: 'Overview', order: 10 },
|
{ id: 'overview', label: 'Overview', order: 10 },
|
||||||
{ id: 'config', label: 'Configuration', order: 20 },
|
{ id: 'config', label: 'Configuration', order: 20 },
|
||||||
|
{ id: 'categories', label: 'Categories', order: 25 },
|
||||||
{ id: 'cookies', label: 'Cookies', order: 30 },
|
{ id: 'cookies', label: 'Cookies', order: 30 },
|
||||||
{ id: 'banner', label: 'Banner', order: 40 },
|
{ id: 'banner', label: 'Banner', order: 40 },
|
||||||
{ id: 'translations', label: 'Translations', order: 50 },
|
{ id: 'translations', label: 'Translations', order: 50 },
|
||||||
@@ -89,6 +91,9 @@ export default function SiteDetailPage() {
|
|||||||
{/* Tab content — core tabs */}
|
{/* Tab content — core tabs */}
|
||||||
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
|
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
|
||||||
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} 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 === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
|
||||||
{activeTab === 'banner' && siteId && (
|
{activeTab === 'banner' && siteId && (
|
||||||
<BannerBuilderTab
|
<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_enabled: true,
|
||||||
scan_frequency_hours: 168,
|
scan_frequency_hours: 168,
|
||||||
scan_max_pages: 50,
|
scan_max_pages: 50,
|
||||||
|
enabled_categories: null,
|
||||||
created_at: '2025-01-01T00:00:00Z',
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
updated_at: '2025-01-01T00:00:00Z',
|
updated_at: '2025-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,10 +129,62 @@ export interface SiteConfig {
|
|||||||
scan_enabled: boolean;
|
scan_enabled: boolean;
|
||||||
scan_frequency_hours: number;
|
scan_frequency_hours: number;
|
||||||
scan_max_pages: 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;
|
created_at: string;
|
||||||
updated_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 {
|
export interface ButtonConfig {
|
||||||
backgroundColour?: string;
|
backgroundColour?: string;
|
||||||
textColour?: string;
|
textColour?: string;
|
||||||
|
|||||||
46
apps/api/alembic/versions/0003_enabled_categories.py
Normal file
46
apps/api/alembic/versions/0003_enabled_categories.py
Normal 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")
|
||||||
@@ -52,6 +52,11 @@ class OrgConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_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
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class SiteConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_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
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
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)
|
scan_max_pages: Mapped[int] = mapped_column(Integer, server_default="50", nullable=False)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class SiteGroupConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_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
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class OrgConfigUpdate(BaseModel):
|
|||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
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_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
consent_retention_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):
|
class OrgConfigResponse(BaseModel):
|
||||||
@@ -55,6 +56,7 @@ class OrgConfigResponse(BaseModel):
|
|||||||
scan_max_pages: int | None
|
scan_max_pages: int | None
|
||||||
consent_expiry_days: int | None
|
consent_expiry_days: int | None
|
||||||
consent_retention_days: int | None
|
consent_retention_days: int | None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class SiteConfigCreate(BaseModel):
|
|||||||
scan_max_pages: int = Field(default=50, ge=1, le=1000)
|
scan_max_pages: int = Field(default=50, ge=1, le=1000)
|
||||||
consent_expiry_days: int = Field(default=365, ge=1, le=730)
|
consent_expiry_days: int = Field(default=365, ge=1, le=730)
|
||||||
consent_retention_days: int | None = Field(default=None, 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):
|
class SiteConfigUpdate(BaseModel):
|
||||||
@@ -87,6 +91,7 @@ class SiteConfigUpdate(BaseModel):
|
|||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
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_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
consent_retention_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):
|
class SiteConfigResponse(BaseModel):
|
||||||
@@ -111,6 +116,7 @@ class SiteConfigResponse(BaseModel):
|
|||||||
scan_max_pages: int = 50
|
scan_max_pages: int = 50
|
||||||
consent_expiry_days: int = 365
|
consent_expiry_days: int = 365
|
||||||
consent_retention_days: int | None = None
|
consent_retention_days: int | None = None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class SiteGroupConfigUpdate(BaseModel):
|
|||||||
scan_schedule_cron: str | None = None
|
scan_schedule_cron: str | None = None
|
||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
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_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SiteGroupConfigResponse(BaseModel):
|
class SiteGroupConfigResponse(BaseModel):
|
||||||
@@ -53,6 +54,7 @@ class SiteGroupConfigResponse(BaseModel):
|
|||||||
scan_schedule_cron: str | None
|
scan_schedule_cron: str | None
|
||||||
scan_max_pages: int | None
|
scan_max_pages: int | None
|
||||||
consent_expiry_days: int | None
|
consent_expiry_days: int | None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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-level defaults (hard-coded, lowest priority)
|
||||||
SYSTEM_DEFAULTS: dict[str, Any] = {
|
SYSTEM_DEFAULTS: dict[str, Any] = {
|
||||||
"blocking_mode": "opt_in",
|
"blocking_mode": "opt_in",
|
||||||
@@ -34,6 +51,10 @@ SYSTEM_DEFAULTS: dict[str, Any] = {
|
|||||||
"privacy_policy_url": None,
|
"privacy_policy_url": None,
|
||||||
"terms_url": None,
|
"terms_url": None,
|
||||||
"consent_expiry_days": 365,
|
"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:
|
if regional_mode:
|
||||||
resolved["blocking_mode"] = regional_mode
|
resolved["blocking_mode"] = regional_mode
|
||||||
|
|
||||||
|
resolved["enabled_categories"] = _normalise_enabled_categories(
|
||||||
|
resolved.get("enabled_categories")
|
||||||
|
)
|
||||||
|
|
||||||
return resolved
|
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(
|
def build_public_config(
|
||||||
site_id: str,
|
site_id: str,
|
||||||
resolved: dict[str, Any],
|
resolved: dict[str, Any],
|
||||||
@@ -108,6 +153,9 @@ def build_public_config(
|
|||||||
"consent_expiry_days": resolved["consent_expiry_days"],
|
"consent_expiry_days": resolved["consent_expiry_days"],
|
||||||
"consent_group_id": resolved.get("consent_group_id"),
|
"consent_group_id": resolved.get("consent_group_id"),
|
||||||
"ab_test": resolved.get("ab_test"),
|
"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",
|
"privacy_policy_url",
|
||||||
"terms_url",
|
"terms_url",
|
||||||
"consent_expiry_days",
|
"consent_expiry_days",
|
||||||
|
"enabled_categories",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.services.config_resolver import (
|
from src.services.config_resolver import (
|
||||||
|
ALL_CATEGORIES,
|
||||||
SYSTEM_DEFAULTS,
|
SYSTEM_DEFAULTS,
|
||||||
|
_normalise_enabled_categories,
|
||||||
build_public_config,
|
build_public_config,
|
||||||
resolve_config,
|
resolve_config,
|
||||||
)
|
)
|
||||||
@@ -256,3 +258,72 @@ class TestConfigRoutes:
|
|||||||
site_id = uuid.uuid4()
|
site_id = uuid.uuid4()
|
||||||
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
|
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
|
||||||
assert resp.status_code == 401
|
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
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ export function registerEEHooks(hooks: Partial<EEHooks>): void {
|
|||||||
// Expose for EE bundle
|
// Expose for EE bundle
|
||||||
(window as any).__consentos_register_ee = registerEEHooks;
|
(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[] = [
|
const ALL_CATEGORIES: CategorySlug[] = [
|
||||||
'necessary',
|
'necessary',
|
||||||
'functional',
|
'functional',
|
||||||
@@ -97,12 +103,33 @@ const ALL_CATEGORIES: CategorySlug[] = [
|
|||||||
'personalisation',
|
'personalisation',
|
||||||
];
|
];
|
||||||
|
|
||||||
const NON_ESSENTIAL: CategorySlug[] = [
|
/**
|
||||||
'functional',
|
* Return the categories the banner should render for this config.
|
||||||
'analytics',
|
* ``necessary`` is always implicit and forced back in if missing;
|
||||||
'marketing',
|
* unknown slugs are filtered; the result is sorted into the canonical
|
||||||
'personalisation',
|
* 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. */
|
/** Initialise the banner. Called when the bundle loads. */
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
@@ -240,6 +267,7 @@ function buildDefaultConfig(siteId: string): SiteConfig {
|
|||||||
consent_group_id: null,
|
consent_group_id: null,
|
||||||
ab_test: null,
|
ab_test: null,
|
||||||
initiator_map: null,
|
initiator_map: null,
|
||||||
|
enabled_categories: [...ALL_CATEGORIES],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +294,9 @@ function renderBanner(
|
|||||||
const titleId = 'cmp-title';
|
const titleId = 'cmp-title';
|
||||||
const descId = 'cmp-desc';
|
const descId = 'cmp-desc';
|
||||||
|
|
||||||
|
const enabledCategories = resolveEnabledCategories(config);
|
||||||
|
const nonEssential = nonEssentialFor(enabledCategories);
|
||||||
|
|
||||||
shadow.innerHTML = `
|
shadow.innerHTML = `
|
||||||
<style>${getBannerStyles(config)}</style>
|
<style>${getBannerStyles(config)}</style>
|
||||||
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
|
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
|
||||||
${renderCategories(t)}
|
${renderCategories(t, enabledCategories)}
|
||||||
</div>
|
</div>
|
||||||
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
|
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
|
||||||
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
|
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
|
||||||
@@ -321,7 +352,7 @@ function renderBanner(
|
|||||||
// Set up keyboard navigation
|
// Set up keyboard navigation
|
||||||
const cleanupFocusTrap = trapFocus(banner);
|
const cleanupFocusTrap = trapFocus(banner);
|
||||||
const cleanupEscape = onEscape(banner, () => {
|
const cleanupEscape = onEscape(banner, () => {
|
||||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,11 +360,12 @@ function renderBanner(
|
|||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
|
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
|
||||||
if (action === 'accept') {
|
if (action === 'accept') {
|
||||||
// Explicit Accept All overrides GPC — user choice takes precedence
|
// Explicit Accept All overrides GPC — user choice takes precedence.
|
||||||
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
|
// "All" only includes the categories the operator has enabled.
|
||||||
|
handleConsent([...enabledCategories], [], config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
} else if (action === 'reject') {
|
} else if (action === 'reject') {
|
||||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
} else if (action === 'settings') {
|
} else if (action === 'settings') {
|
||||||
const isHidden = categoriesDiv.style.display === 'none';
|
const isHidden = categoriesDiv.style.display === 'none';
|
||||||
@@ -342,7 +374,7 @@ function renderBanner(
|
|||||||
announce(liveRegion, isHidden ? t.managePreferences : t.title);
|
announce(liveRegion, isHidden ? t.managePreferences : t.title);
|
||||||
} else if (action === 'save') {
|
} else if (action === 'save') {
|
||||||
const accepted = getSelectedCategories(shadow);
|
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);
|
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
}
|
}
|
||||||
@@ -355,16 +387,20 @@ function renderBanner(
|
|||||||
focusFirst(banner);
|
focusFirst(banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render category toggles HTML. */
|
/** Render category toggles HTML. Only renders the categories the
|
||||||
function renderCategories(t: TranslationStrings): string {
|
* config has enabled — ``necessary`` is always present and locked. */
|
||||||
const categories = [
|
function renderCategories(t: TranslationStrings, enabled: CategorySlug[]): string {
|
||||||
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
|
const all = [
|
||||||
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
|
{ slug: 'necessary' as const, name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
|
||||||
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
|
{ slug: 'functional' as const, name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
|
||||||
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
|
{ slug: 'analytics' as const, name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
|
||||||
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, 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 (
|
return (
|
||||||
categories
|
categories
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export interface SiteConfig {
|
|||||||
ab_test: ABTestConfig | null;
|
ab_test: ABTestConfig | null;
|
||||||
/** Initiator map: root script URL → category for root-level blocking. */
|
/** Initiator map: root script URL → category for root-level blocking. */
|
||||||
initiator_map: InitiatorMapping[] | null;
|
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. */
|
/** Maps a root initiator script to the cookie category it ultimately sets. */
|
||||||
|
|||||||
Reference in New Issue
Block a user