feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
**/.git
**/__pycache__
**/node_modules
**/.venv
**/*.pyc
.env
*.md
docs/

68
.env.example Normal file
View File

@@ -0,0 +1,68 @@
# Application
APP_NAME=ConsentOS API
DEBUG=true
ENVIRONMENT=development
LOG_LEVEL=DEBUG
# Database
DATABASE_URL=postgresql+asyncpg://consentos:consentos@postgres:5432/consentos
DATABASE_ECHO=false
# Redis
REDIS_URL=redis://redis:6379/0
# JWT — generate with `openssl rand -hex 32` for production
JWT_SECRET_KEY=dev-secret-change-in-production
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
# CDN — public URL where banner scripts are served. In dev the admin
# UI dog-foods the banner from its own /banner/ path, so localhost:5173
# works. In production point this at your real CDN (CloudFlare Pages,
# S3 + CloudFront, etc.) where consent-loader.js / consent-bundle.js
# are hosted.
CDN_BASE_URL=http://localhost:5173
# CORS — comma-separated list of allowed origins. Wildcards are refused
# at startup when ENVIRONMENT is not dev/test.
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8000
# Required to enable POST /api/v1/organisations/. Set to a strong random
# value (`openssl rand -hex 32`) to bootstrap your first organisation,
# then unset or rotate.
# ADMIN_BOOTSTRAP_TOKEN=
# Extra GeoIP country header — checked before the built-in list
# (cf-ipcountry, x-vercel-ip-country, x-appengine-country,
# x-country-code). Set this when you're behind a CDN or load
# balancer that uses a non-standard header, e.g. Google Cloud Load
# Balancer's x-gclb-country. Header names are case-insensitive.
# GEOIP_COUNTRY_HEADER=x-gclb-country
# Companion subdivision/state header. When both are set, the API
# pairs them to produce keys like "US-CA" or "GB-SCT" (ISO 3166-2).
# Only applies alongside GEOIP_COUNTRY_HEADER. Common names:
# cf-region-code (Cloudflare Enterprise)
# x-vercel-ip-country-region (Vercel)
# x-gclb-region (Google Cloud Load Balancer)
# cloudfront-viewer-country-region (AWS CloudFront functions)
# GEOIP_REGION_HEADER=x-gclb-region
# Local MaxMind GeoLite2-City database — used as a fallback when no
# CDN header is present. Download GeoLite2-City.mmdb from MaxMind
# (free, registration required) and mount it into the container,
# then point at it here. Without this, the API falls back to the
# external ip-api.com service which is rate-limited and should not
# be relied on in production.
# GEOIP_MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb
# Initial admin bootstrap — on first startup, if the users table is
# empty and both credentials below are set, the API creates an
# organisation and an owner user so you can log in to the admin UI.
# Rotate the password via the admin UI after first login. Once any
# user exists this is a no-op, so the variables can safely remain set
# across restarts.
# INITIAL_ADMIN_EMAIL=admin@example.com
# INITIAL_ADMIN_PASSWORD=change-me-immediately
# INITIAL_ADMIN_FULL_NAME=Administrator
# INITIAL_ORG_NAME=Default Organisation
# INITIAL_ORG_SLUG=default

28
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,28 @@
---
name: Bug report
about: Report a bug or unexpected behaviour
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear description of what the bug is.
**To reproduce**
Steps to reproduce the behaviour:
1. ...
2. ...
3. ...
**Expected behaviour**
What you expected to happen.
**Environment**
- ConsentOS version:
- Deployment method: (Docker Compose / Helm / Ansible)
- Browser (if banner-related):
- OS:
**Additional context**
Any logs, screenshots, or configuration that might help.

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest a new feature or enhancement
title: ''
labels: enhancement
assignees: ''
---
**Problem**
What problem does this feature solve?
**Proposed solution**
Describe what you'd like to happen.
**Alternatives considered**
Any alternative approaches you've thought about.
**Additional context**
Any mockups, references, or related issues.

17
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,17 @@
## Summary
<!-- Brief description of what this PR does and why -->
## Changes
-
## Test plan
- [ ] Tests added/updated
- [ ] `make check` passes locally
- [ ] Manual testing done (if applicable)
## Related issues
<!-- Link any related issues: Fixes #123, Relates to #456 -->

213
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,213 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
api-lint:
name: API Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install ruff
- run: ruff check src/ tests/
- run: ruff format --check src/ tests/
api-test:
name: API Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: consentos_test
POSTGRES_PASSWORD: consentos_test
POSTGRES_DB: consentos_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://consentos_test:consentos_test@localhost:5432/consentos_test
REDIS_URL: redis://localhost:6379/0
JWT_SECRET_KEY: ci-test-secret-key-not-for-production
ENVIRONMENT: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e ".[dev]"
- name: Run migrations
run: alembic upgrade head
env:
DATABASE_URL: postgresql://consentos_test:consentos_test@localhost:5432/consentos_test
- run: pytest --cov=src --cov-report=term-missing -v
scanner-lint:
name: Scanner Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/scanner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install ruff
- run: ruff check src/ tests/
- run: ruff format --check src/ tests/
scanner-test:
name: Scanner Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/scanner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e ".[dev]"
- run: pytest --cov=src --cov-report=term-missing -v
banner-lint:
name: Banner Lint & Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/banner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/banner/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
banner-test:
name: Banner Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/banner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/banner/package-lock.json
- run: npm ci
- run: npx vitest run --reporter=verbose
banner-build:
name: Banner Build
runs-on: ubuntu-latest
needs: [banner-test, banner-lint]
defaults:
run:
working-directory: apps/banner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/banner/package-lock.json
- run: npm ci
- run: npm run build
- name: Check bundle sizes
run: |
echo "=== Bundle sizes ==="
ls -lh dist/consent-loader.js
ls -lh dist/consent-bundle.js
LOADER_SIZE=$(stat -c%s dist/consent-loader.js)
if [ "$LOADER_SIZE" -gt 20480 ]; then
echo "::warning::consent-loader.js is ${LOADER_SIZE} bytes (>20KB) — consider optimising"
fi
admin-ui-lint:
name: Admin UI Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/admin-ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/admin-ui/package-lock.json
- run: npm ci
- run: npx tsc -b
admin-ui-test:
name: Admin UI Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/admin-ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/admin-ui/package-lock.json
- run: npm ci
- run: npx vitest run --reporter=verbose
admin-ui-build:
name: Admin UI Build
runs-on: ubuntu-latest
needs: [admin-ui-test, admin-ui-lint]
defaults:
run:
working-directory: apps/admin-ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/admin-ui/package-lock.json
- run: npm ci
- run: npx vite build

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
*.egg
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
.env.*.local
# Testing
.pytest_cache/
.coverage
htmlcov/
coverage/
.mypy_cache/
# Node
node_modules/
.turbo/
# Banner build artefacts copied into admin-ui during Pages build
apps/admin-ui/public/consent-*.js
apps/admin-ui/public/consent-*.js.map
# OS
.DS_Store
Thumbs.db
**/*.vault.yml
# Ansible secrets
infra/ansible/.vault_pass
# Claude Code workspace
.claude/
# CodeGraph cache
.codegraph/

23
CHANGELOG.md Normal file
View File

@@ -0,0 +1,23 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-03-18
Initial public release of ConsentOS.
### Added
- **API:** FastAPI backend with JWT authentication, org/site CRUD, consent recording, analytics, and compliance checking
- **Banner:** Lightweight consent banner script (~2KB loader + ~25KB bundle) with Shadow DOM isolation, auto-blocking, IAB TCF v2.2, and Google Consent Mode v2
- **Scanner:** Playwright-based cookie crawler with auto-categorisation and dark pattern detection
- **Admin UI:** React dashboard with site management, cookie manager, banner builder, compliance checker, and analytics
- **Known cookies:** Seeded from the [Open Cookie Database](https://github.com/jkwakman/Open-Cookie-Database) (2,200+ patterns)
- **Compliance:** Rule-based engine covering GDPR, CNIL, CCPA/CPRA, ePrivacy, and LGPD
- **Infrastructure:** Docker Compose (dev/test/prod), Helm chart, Ansible playbooks
- **CI:** GitHub Actions pipeline with linting, testing, type checking, and bundle size checks

291
CLAUDE.md Normal file
View File

@@ -0,0 +1,291 @@
# ConsentOS
## Project Overview
ConsentOS is a multi-tenant cookie consent management platform — a source-available alternative to OneTrust, Cookiebot, and CookieYes — that provides cookie scanning, consent collection, auto-blocking, and compliance checking across many sites with per-site configuration.
The platform delivers a single `<script>` tag that site owners embed. This script handles consent collection, cookie blocking, IAB TCF v2.2, and Google Consent Mode v2 signalling. A separate admin dashboard allows site owners to manage configurations, review scan results, and check compliance.
**Public repo:** [github.com/consentos/consentos](https://github.com/consentos/consentos)
**Domain:** [consentos.dev](https://consentos.dev)
## Architecture Summary
```
CDN (static assets)
├── consent-loader.js (~2KB gzipped, sync bootstrap)
├── consent-bundle-{v}.js (~25KB gzipped, full banner + blocker)
├── site-config-{id}.json (cached site configuration)
└── translations-{locale}.json
Client Browser
├── Script Interceptor (MutationObserver + createElement override)
├── Cookie Blocker (document.cookie proxy, Storage proxy)
├── Banner UI (Shadow DOM, customisable, a11y-compliant)
├── TCF v2.2 API (__tcfapi)
├── Google Consent Mode v2 (gtag integration)
├── Client-side Cookie Reporter
└── Consent State Manager
API Layer (FastAPI)
├── Config API — site/org CRUD, banner config, allow-lists, CDN publishing
├── Consent API — consent recording, retrieval, TC string generation, analytics
├── Scanner API — scan management, client-side cookie reports
└── Admin BFF — aggregates the above for the admin UI
Scanner Service (Python + Playwright)
├── Scheduled headless browser crawls
├── Cookie discovery and script attribution
└── Auto-categorisation via known cookies DB
PostgreSQL — all persistent state
Redis — caching, rate limiting, Celery job queue
Admin UI (Vite + React + TypeScript)
├── Site management, configuration editor
├── Cookie manager, allow-list management
├── Banner builder (visual editor with live preview)
├── Compliance checker (GDPR, CNIL, CCPA, ePrivacy, LGPD)
└── Analytics dashboard (consent rates, trends, regional)
```
## Technology Stack
### Backend (`apps/api/`)
- **Language:** Python 3.12+
- **Framework:** FastAPI
- **ORM:** SQLAlchemy 2.0 (async)
- **Migrations:** Alembic
- **Database:** PostgreSQL 16
- **Cache/Queue:** Redis + Celery
- **Auth:** JWT (org-scoped, role-based)
- **Validation:** Pydantic v2
### Scanner (`apps/scanner/`)
- **Language:** Python 3.12+
- **Browser automation:** Playwright
- **Job scheduling:** Celery + Redis
### Banner Script (`apps/banner/`)
- **Language:** TypeScript
- **Build:** Rollup (outputs IIFE bundles)
- **UI isolation:** Shadow DOM
- **Standards:** IAB TCF v2.2, Google Consent Mode v2
### Admin UI (`apps/admin-ui/`)
- **Framework:** Vite + React + TypeScript
- **Primary UI:** shadcn/ui + TailwindCSS
- **Complex components:** MUI (DataGrid for tables, charts)
- **Server state:** TanStack Query
- **Client state:** Zustand
- **Routing:** React Router v6
- **Forms:** React Hook Form + Zod
- **i18n:** react-i18next
### Infrastructure
- **Containerisation:** Docker / Docker Compose
- **Orchestration:** Kubernetes (Helm chart)
- **CDN:** Cloud-agnostic (CloudFlare, Cloud CDN, or CloudFront)
## Project Structure
```
consent-platform/
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── src/
│ │ │ ├── config/ # Pydantic settings, environment
│ │ │ ├── models/ # SQLAlchemy models
│ │ │ ├── schemas/ # Pydantic request/response schemas
│ │ │ ├── routers/ # API route handlers
│ │ │ │ ├── config.py # site/org config endpoints
│ │ │ │ ├── consent.py # consent recording/retrieval
│ │ │ │ ├── scanner.py # scan management
│ │ │ │ ├── analytics.py # analytics endpoints
│ │ │ │ ├── compliance.py # compliance checker
│ │ │ │ └── auth.py # authentication
│ │ │ ├── services/ # Business logic
│ │ │ │ ├── consent.py
│ │ │ │ ├── tcf.py # TC string encoding/decoding
│ │ │ │ ├── gcm.py # Google Consent Mode logic
│ │ │ │ ├── compliance.py # Compliance rule engine
│ │ │ │ ├── publisher.py # CDN publishing
│ │ │ │ └── classification.py # Cookie auto-categorisation
│ │ │ ├── db/ # Database connection, session
│ │ │ └── main.py
│ │ ├── tests/
│ │ ├── alembic/
│ │ ├── pyproject.toml
│ │ └── Dockerfile
│ │
│ ├── scanner/ # Cookie scanner service
│ │ ├── src/
│ │ │ ├── crawler.py # Playwright-based crawler
│ │ │ ├── classifier.py # Cookie classification
│ │ │ ├── scheduler.py # Scan job scheduling
│ │ │ └── worker.py # Celery worker
│ │ ├── Dockerfile
│ │ └── pyproject.toml
│ │
│ ├── admin-ui/ # Vite + React + TS admin dashboard
│ │ ├── src/
│ │ │ ├── components/
│ │ │ ├── pages/
│ │ │ ├── hooks/
│ │ │ ├── api/ # TanStack Query hooks
│ │ │ ├── stores/ # Zustand stores
│ │ │ └── i18n/
│ │ ├── package.json
│ │ ├── vite.config.ts
│ │ ├── tsconfig.json
│ │ └── tailwind.config.ts
│ │
│ └── banner/ # Client-side consent banner
│ ├── src/
│ │ ├── loader.ts # Lightweight bootstrap (~2KB)
│ │ ├── banner.ts # Banner UI engine
│ │ ├── blocker.ts # Script/cookie interceptor
│ │ ├── tcf.ts # TCF v2.2 API implementation
│ │ ├── gcm.ts # Google Consent Mode v2
│ │ ├── reporter.ts # Client-side cookie reporter
│ │ ├── consent.ts # Consent state management
│ │ ├── i18n.ts # Translation loader
│ │ ├── a11y.ts # Accessibility utilities
│ │ └── types.ts
│ ├── rollup.config.js
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ └── shared/ # Shared types, constants, utils
├── helm/consentos/ # Kubernetes deployment
├── docker-compose.yml
├── Makefile
└── README.md
```
## Key Data Entities
- **organisations** — multi-tenant root, each org has multiple sites
- **users** — org-scoped with roles: owner, admin, editor, viewer
- **sites** — a domain being managed (e.g. example.com), belongs to an org
- **site_configs** — full configuration per site: blocking mode, TCF settings, GCM defaults, banner config JSON, scan schedule, consent expiry
- **cookie_categories** — taxonomy (necessary, functional, analytics, marketing, personalisation) with TCF purpose and GCM consent type mappings
- **cookies** — discovered cookies per site with metadata, vendor, category, review status
- **cookie_allow_list** — approved cookies per site with category assignment
- **known_cookies** — shared knowledge base of known cookie patterns for auto-categorisation
- **consent_records** — audit trail of every consent event (partitioned by month)
- **scan_jobs** / **scan_results** — scanning pipeline state and results
- **translations** — i18n strings per site per locale
## Configuration Hierarchy
Configuration resolves in this order (each level overrides the previous):
```
System Defaults (code) → Organisation Defaults → Site Config → Regional Overrides
```
The `site_configs.regional_modes` JSONB field allows per-region blocking mode:
```json
{"EU": "opt_in", "GB": "opt_in", "US-CA": "opt_out", "BR": "opt_in", "DEFAULT": "opt_in"}
```
## Consent Flow
1. Site loads `consent-loader.js` (sync, before other scripts)
2. Loader reads existing consent cookie — if valid, applies consent state and exits
3. If no consent: installs script interceptor, blocks non-essential scripts/cookies
4. Sets Google Consent Mode defaults (`gtag('consent', 'default', {...})`)
5. Installs `__tcfapi` stub for TCF v2.2
6. Async-loads full banner bundle + site config from CDN
7. Banner displays; user interacts
8. On consent action: generates TC string, sets first-party cookie, calls `gtag('consent', 'update', {...})`, releases blocked scripts by category
9. POSTs consent record to Consent API for server-side audit storage
10. Fires `consent-change` custom event + dataLayer push for GTM
## Banner Script Architecture
The banner is split into two files for performance:
- **consent-loader.js** (~2KB gzipped) — synchronous critical path: consent cookie read, GCM defaults, TCF stub, script interceptor installation, async bundle load
- **consent-bundle-{version}.js** (~25KB gzipped) — full UI, consent engine, TCF encoder, reporter
The banner UI renders inside **Shadow DOM** for complete style isolation from the host site.
**Display modes:** overlay (full-screen modal), bottom_banner, top_banner, corner_popup, inline (into specific DOM element)
**Auto-blocking works by:**
- Overriding `document.createElement` to intercept `<script>` tag creation
- `MutationObserver` on `<head>` and `<body>` for dynamically inserted scripts
- Proxying `document.cookie` setter to block writes from non-essential categories
- Wrapping `localStorage.setItem` and `sessionStorage.setItem`
- Maintaining a queue of blocked scripts, released per-category when consent is granted
## Compliance Frameworks
The compliance engine is rule-based. Each framework is a set of `ComplianceRule` objects:
- **GDPR** — opt-in, reject = accept prominence, granular consent, proof of consent, no cookie walls, no pre-ticked boxes
- **CNIL** — all GDPR rules plus: Tout refuser on first layer, max 13-month cookie lifetime, max 6-month consent retention, re-consent every 6 months
- **CCPA/CPRA** — opt-out model, Do Not Sell link, honour GPC signal, under-16 opt-in
- **ePrivacy** — consent for non-essential, strictly necessary exempt
- **LGPD** — consent or legitimate interest basis, identify data controller
Rules output: severity (critical/warning/info), message, recommendation. Aggregated into per-framework scores.
## Coding Conventions
- **Language:** British English throughout (code comments, UI strings, documentation)
- **Python:** Use `pyproject.toml`, type hints everywhere, async where possible
- **SQL:** CTEs over subqueries, no `SELECT *`, explicit column lists
- **TypeScript:** strict mode, explicit return types on exported functions
- **Git:** conventional commits (`feat:`, `fix:`, `chore:`, `docs:`)
- **Testing:** pytest for Python, Vitest for TypeScript, aim for >80% coverage on services
- **API design:** RESTful, Pydantic schemas for all request/response bodies, consistent error format
- **Database:** UUID primary keys, `created_at`/`updated_at` timestamps on all tables, soft deletes where appropriate
## Development Environment
```bash
# Start everything
docker compose up -d
# Run migrations
make migrate
# Seed default data (cookie categories, known cookies)
make seed
# Run tests
make test
# Lint
make lint
```
Services in Docker Compose:
- `api` — FastAPI on port 8000
- `scanner` — Playwright scanner service
- `postgres` — PostgreSQL 16 on port 5432
- `redis` — Redis on port 6379
- `admin-ui` — Vite dev server on port 5173 (also dog-foods the banner)
## Implementation Phases
| Phase | Scope |
|-------|-------|
| 1 (Weeks 13) | DB schema, FastAPI scaffold, auth, site CRUD, basic banner, consent API, Docker Compose |
| 2 (Weeks 46) | TCF v2.2, Google Consent Mode v2, script interceptor/auto-blocking, cookie categories, allow-list, config hierarchy, admin UI scaffold |
| 3 (Weeks 78) | Playwright crawler, auto-categorisation, client-side reporter, scan scheduling, admin UI for scans |
| 4 (Weeks 910) | Compliance rule engine (GDPR/CNIL/CCPA/ePrivacy/LGPD), consent analytics API, compliance + analytics admin UI |
| 5 (Weeks 1112) | Banner builder (visual editor), all display modes, full i18n, a11y audit, GeoIP, multi-domain, Helm chart, security hardening, load testing |
## Key External Standards
- **IAB TCF v2.2:** [IAB TCF Technical Specification](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md)
- **Google Consent Mode v2:** [Google Developer Docs](https://developers.google.com/tag-platform/security/guides/consent)
- **Global Vendor List (GVL):** Loaded from IAB, cached, updated regularly
- **WCAG 2.1 AA:** Accessibility target for the banner UI

61
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,61 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, colour, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behaviour that contributes to a positive environment:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behaviour:
* The use of sexualised language or imagery, and sexual attention or advances
of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behaviour and will take appropriate and fair corrective action in
response to any behaviour that they deem inappropriate, threatening, offensive,
or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
reported to the community leaders at **conduct@consentos.dev**.
All complaints will be reviewed and investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html

118
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,118 @@
# Contributing to ConsentOS
Thanks for your interest in contributing! This document explains how to get started.
## Code of Conduct
This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
## Getting Started
### Prerequisites
- Docker and Docker Compose v2.15+
- Node.js 20+
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) (Python package manager)
### Local Setup
```bash
# Clone the repository
git clone https://github.com/consentos/consentos.git
cd consentos
# Copy the example environment file
cp .env.example .env
# Start all services
make up
# Run database migrations
make migrate
# Seed the known cookies database
make seed
# Verify everything is running
# API: http://localhost:8000/docs
# Admin UI: http://localhost:5173
# CDN: http://localhost:8080
```
### Running Tests
```bash
# Start test infrastructure (PostgreSQL + Redis)
make test-infra-up
# Run API tests
make test
# Run with coverage
make test-cov
# Run banner tests
cd apps/banner && npm test
# Run admin UI tests
cd apps/admin-ui && npm test
# Stop test infrastructure
make test-infra-down
```
## Making Changes
### Branch Naming
Create a branch from `master` using the convention:
```
<type>/<short-description>
```
Examples: `feat/add-geo-rules`, `fix/consent-cookie-expiry`, `docs/api-examples`
### Commit Messages
We use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add regional consent mode overrides
fix: correct TC string encoding for vendor consents
docs: document compliance rule engine
chore: update Python dependencies
refactor: simplify cookie classification pipeline
test: add integration tests for scanner API
```
### Code Standards
- **Python:** Type hints everywhere. Linted with Ruff, type-checked with MyPy (strict mode)
- **TypeScript:** Strict mode enabled. Linted with ESLint
- **SQL:** CTEs over subqueries, explicit column lists (no `SELECT *`)
- **Language:** British English in all prose, comments, and UI strings
### Before Submitting
1. Run `make check` (lint + tests) and ensure it passes
2. Add or update tests for any changed behaviour
3. Ensure no secrets or credentials are committed
## Pull Requests
- Keep PRs focused — one logical change per PR
- Write a clear title (under 70 characters) and description
- Link to any related issues
- All CI checks must pass before merge
- PRs require at least one approving review
## Reporting Issues
- Use [GitHub Issues](https://github.com/consentos/consentos/issues) for bugs and feature requests
- For security vulnerabilities, see [SECURITY.md](SECURITY.md)
## Licence
By contributing, you agree that your contributions will be licensed under the [Elastic License 2.0](LICENSE).

96
LICENSE Normal file
View File

@@ -0,0 +1,96 @@
Elastic License 2.0 (ELv2)
## Acceptance
By using the software, you agree to all of the terms and conditions below.
## Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide,
non-sublicensable, non-transferable license to use, copy, distribute, make
available, and prepare derivative works of the software, in each case subject
to the limitations and conditions below.
## Limitations
You may not provide the software to third parties as a hosted or managed
service, where the service provides users with access to any substantial set
of the features or functionality of the software.
You may not move, change, disable, or circumvent the license key functionality
in the software, and you may not remove or obscure any functionality in the
software that is protected by the license key.
You may not alter, remove, or obscure any licensing, copyright, or other
notices of the licensor in the software. Any use of the licensor's trademarks
is subject to applicable law.
## Patents
The licensor grants you a license, under any patent claims the licensor can
license, or becomes able to license, to make, have made, use, sell, offer for
sale, import and have imported the software, in each case subject to the
limitations and conditions in this license. This license does not cover any
patent claims that you cause to be infringed by modifications or additions to
the software. If you or your company make any written claim that the software
infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company
makes such a claim, your patent license ends immediately for work on behalf
of your company.
## Notices
You must ensure that anyone who gets a copy of any part of the software from
you also gets a copy of these terms.
If you modify the software, you must include in any modified copies of the
software prominent notices stating that you have modified the software.
## No Other Rights
These terms do not imply any licenses other than those expressly granted in
these terms.
## Termination
If you use the software in violation of these terms, such use is not licensed,
and your licenses will automatically terminate. If the licensor provides you
with a notice of your violation, and you cease all violation of this license
no later than 30 days after you receive that notice, your licenses will be
reinstated retroactively. However, if you violate these terms after such
reinstatement, any additional violation of these terms will cause your
licenses to terminate automatically and permanently.
## No Liability
*As far as the law allows, the software comes as is, without any warranty or
condition, and the licensor will not be liable to you for any damages arising
out of these terms or the use or nature of the software, under any kind of
legal claim.*
## Definitions
The **licensor** is the entity offering these terms, and the **software** is
the software the licensor makes available under these terms, including any
portion of it.
**you** refers to the individual or entity agreeing to these terms.
**your company** is any legal entity, sole proprietorship, or other kind of
organisation that you work for, plus all organisations that have control over,
are under the control of, or are under common control with that organisation.
**control** means ownership of substantially all the assets of an entity, or
the power to direct its management and policies by vote, contract, or
otherwise. Control can be direct or indirect.
**your licenses** are all the licenses granted to you for the software under
these terms.
**use** means anything you do with the software requiring one of your
licenses.
**trademark** means trademarks, service marks, and similar rights.
---
Copyright 2025-2026 Slate Data Ltd

66
Makefile Normal file
View File

@@ -0,0 +1,66 @@
.PHONY: up down restart logs migrate seed test lint fmt check clean test-infra-up test-infra-down
# ── Development environment ──────────────────────────────────────────
up:
docker compose up -d
down:
docker compose down
restart:
docker compose restart
logs:
docker compose logs -f
logs-api:
docker compose logs -f api
# ── Database ─────────────────────────────────────────────────────────
migrate:
docker compose exec api alembic upgrade head
migrate-offline:
cd apps/api && DATABASE_URL=postgresql+asyncpg://consentos:consentos@localhost:5432/consentos alembic upgrade head
seed: migrate
docker compose exec api python -m src.cli.seed_known_cookies --clear
rollback:
docker compose exec api alembic downgrade -1
# ── Testing ──────────────────────────────────────────────────────────
test-infra-up:
docker compose -f docker-compose.test.yml up -d
docker compose -f docker-compose.test.yml exec -T postgres-test sh -c 'until pg_isready -U consentos_test; do sleep 1; done'
test-infra-down:
docker compose -f docker-compose.test.yml down -v
test:
cd apps/api && python -m pytest tests/ -v --tb=short
test-cov:
cd apps/api && python -m pytest tests/ -v --cov=src --cov-report=term-missing --tb=short
# ── Code quality ─────────────────────────────────────────────────────
lint:
cd apps/api && ruff check src/ tests/ alembic/
fmt:
cd apps/api && ruff check --fix src/ tests/ alembic/
cd apps/api && ruff format src/ tests/
check: lint test
# ── Cleanup ──────────────────────────────────────────────────────────
clean:
docker compose down -v
docker compose -f docker-compose.test.yml down -v
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true

182
README.md Normal file
View File

@@ -0,0 +1,182 @@
<p align="center">
<img src="assets/brand/logo-lockup.svg" alt="ConsentOS" width="260">
</p>
<h1 align="center">Privacy infrastructure for the modern web</h1>
<p align="center">
A self-hosted, multi-tenant cookie consent management platform.<br>
Source-available alternative to OneTrust, Cookiebot and CookieYes.
</p>
<p align="center">
<a href="https://github.com/consentos/consentos/actions"><img src="https://img.shields.io/github/actions/workflow/status/consentos/consentos/ci.yml?branch=master&label=CI&style=flat-square" alt="CI status"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/licence-Elastic--2.0-1B3C7C?style=flat-square" alt="Elastic Licence 2.0"></a>
<a href="https://consentos.dev"><img src="https://img.shields.io/badge/site-consentos.dev-2C6AE4?style=flat-square" alt="consentos.dev"></a>
</p>
---
ConsentOS gives you a single `<script>` tag to embed on your site and a self-hosted dashboard to manage everything behind it: consent collection, cookie blocking, scanning, compliance checking, and audit trails. The full surface — banner, API, scanner, admin UI — is in this repository, with no SaaS lock-in.
## Why ConsentOS
- **Privacy by design, not by default.** Consent is given, not assumed. Auto-blocking is on by default; visitors don't get tracked until they opt in.
- **Standards-complete.** IAB TCF v2.2, GPP v1 (six US state sections), Google Consent Mode v2, GPC, Shopify Customer Privacy API.
- **Yours to host.** Source-available under the Elastic Licence 2.0 — you can self-host indefinitely, modify freely, and run it on your own infrastructure.
- **Built for compliance teams.** Rule-based compliance checks for GDPR, CNIL, CCPA/CPRA, ePrivacy and LGPD, plus a tamper-evident consent record audit trail.
- **Multi-tenant from day one.** Organisations, sites, role-based access. Configuration cascades System → Org → Site Group → Site → Region.
## Features
- **Consent banner** — ~2KB loader + ~26KB bundle, gzipped, rendered in a Shadow DOM root for total style isolation
- **Auto-blocking** — intercepts script creation, cookie writes, and storage API calls until consent is granted; releases per-category
- **Cookie scanner** — Playwright-driven crawl with auto-categorisation against the [Open Cookie Database](https://github.com/jkwakman/Open-Cookie-Database) (2,200+ patterns)
- **Dark pattern detection** — flags pre-ticked boxes, missing reject buttons, button asymmetry, scroll-based dismissal
- **Compliance engine** — rules for GDPR, CNIL, CCPA/CPRA, ePrivacy, LGPD with severity scoring
- **Configuration cascade** — defaults → org → site group → site → regional override
- **Display modes** — bottom banner, top banner, overlay modal, corner popup, inline
- **Consent withdrawal** — persistent floating button so visitors can change their mind (GDPR Art. 7(3))
- **i18n-ready banner** — translations API per site, locale auto-detection
- **GeoIP-aware** — region-specific consent modes (opt-in for EU, opt-out for US-CA, etc.)
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌─────────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Consent │ │ Script │ │ Banner UI │ │
│ │ Loader (2KB)│→ │ Blocker │ │ (Shadow DOM) │ │
│ └──────┬──────┘ └──────────┘ └───────────────┘ │
│ │ TCF v2.2 · GCM v2 · GPP v1 · GPC │
└─────────┼───────────────────────────────────────────┘
┌─────────────────────┐ ┌──────────────────────┐
│ FastAPI Backend │ │ Scanner Service │
│ · Config API │ │ · Playwright crawler│
│ · Consent API │ │ · Auto-categoriser │
│ · Compliance API │ │ · Celery worker │
└─────────┬───────────┘ └──────────────────────┘
┌─────┴──────┐
│ PostgreSQL │ Redis (cache + queue)
└────────────┘
```
## Quick start
### Prerequisites
- Docker and Docker Compose v2.15+
- Node.js 20+ and npm
- Python 3.12+ and [uv](https://docs.astral.sh/uv/)
### Setup
```bash
# Clone and configure
git clone https://github.com/consentos/consentos.git
cd consentos
cp .env.example .env
# Start the dev environment
make up
# Run migrations and seed cookie categories
make seed
```
| Service | URL |
|-----------|----------------------------|
| API docs | http://localhost:8000/docs |
| Admin UI | http://localhost:5173 |
The admin UI dog-foods the banner script at `http://localhost:5173/banner/consent-loader.js`. In production you'd publish those files to a CDN and point `CDN_BASE_URL` at it.
### Bootstrapping the first organisation
The `POST /api/v1/organisations/` endpoint is gated behind a static admin token by default. To create your initial organisation:
1. Set `ADMIN_BOOTSTRAP_TOKEN` in `.env` to a strong random value (`openssl rand -hex 32`)
2. Restart the API
3. `curl -X POST http://localhost:8000/api/v1/organisations/ -H "X-Admin-Bootstrap-Token: <your-token>" -H "Content-Type: application/json" -d '{"name": "Acme", "slug": "acme"}'`
4. Unset or rotate `ADMIN_BOOTSTRAP_TOKEN` once your org is created — leaving it set means anyone with the value can keep creating tenants.
### Running tests
```bash
make test-infra-up # Start test PostgreSQL + Redis
make test # Run API tests
make test-cov # With coverage
make test-infra-down # Tear down
```
Banner and admin UI tests:
```bash
cd apps/banner && npm test
cd apps/admin-ui && npm test
```
## Project structure
```
consentos/
├── apps/
│ ├── api/ # FastAPI backend (Python)
│ ├── scanner/ # Playwright cookie scanner (Python)
│ ├── banner/ # Consent banner script (TypeScript)
│ └── admin-ui/ # Admin dashboard (React + TypeScript)
├── assets/brand/ # Logo, palette, brand guidelines
├── helm/ # Kubernetes Helm chart
├── sdks/ # Mobile SDKs (iOS, Android)
├── docker-compose.yml # Development environment
└── Makefile
```
## Technology
| Layer | Stack |
|-----------|---------------------------------------------------------|
| API | Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic |
| Scanner | Python 3.12, Playwright, Celery |
| Banner | TypeScript, Rollup, Shadow DOM |
| Admin UI | React 19, Vite, shadcn/ui, TailwindCSS, TanStack Query |
| Database | PostgreSQL 16 |
| Cache | Redis 7 |
| Infra | Docker Compose, Kubernetes (Helm), Ansible |
## Known cookies database
ConsentOS ships with the [Open Cookie Database](https://github.com/jkwakman/Open-Cookie-Database) — a community-maintained catalogue of 2,200+ cookie patterns used for auto-categorisation during scans. To update:
```bash
curl -L https://raw.githubusercontent.com/jkwakman/Open-Cookie-Database/master/open-cookie-database.csv \
-o apps/api/data/open-cookie-database.csv
make seed
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, coding standards, and PR guidelines. We follow [Conventional Commits](https://www.conventionalcommits.org/) and write everything in British English.
## Security
To report a vulnerability, see [SECURITY.md](SECURITY.md). Please do not open public issues for security reports.
## Licence
ConsentOS is licensed under the [Elastic Licence 2.0 (ELv2)](LICENSE) — a source-available licence.
You may **use, copy, distribute, and modify** the software freely, with two restrictions:
1. You may not provide it to third parties as a hosted or managed service
2. You may not circumvent any licence key functionality
This means: self-host it on your own infrastructure as much as you like; offer it to your customers as part of a wider product; modify it to your heart's content. You just can't resell ConsentOS itself as a SaaS — that's how the project sustains itself.
The known cookies database (`apps/api/data/open-cookie-database.csv`) is sourced from the [Open Cookie Database](https://github.com/jkwakman/Open-Cookie-Database) under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
See the [LICENSE](LICENSE) file for the full licence text and copyright notice.

34
SECURITY.md Normal file
View File

@@ -0,0 +1,34 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| 0.1.x | Yes |
## Reporting a Vulnerability
If you discover a security vulnerability, **please do not open a public issue.**
Instead, email **security@consentos.dev** with:
- A description of the vulnerability
- Steps to reproduce
- Any relevant logs or screenshots
- Your assessment of severity
We aim to acknowledge reports within **48 hours** and provide a fix or mitigation plan within **7 days** for critical issues.
## Scope
The following are in scope for security reports:
- The ConsentOS API (`apps/api/`)
- The consent banner script (`apps/banner/`)
- The scanner service (`apps/scanner/`)
- The admin UI (`apps/admin-ui/`)
- Docker and Helm deployment configurations
## Responsible Disclosure
We ask that you give us reasonable time to address any reported vulnerabilities before disclosing them publicly, remembering that this is a free, open source project and not paid work. We are happy to credit researchers who report valid issues (unless you prefer to remain anonymous).

View File

@@ -0,0 +1,4 @@
node_modules
dist
.git
*.md

View File

@@ -0,0 +1,8 @@
# Production environment — loaded automatically by Vite during `vite build`.
# Same-origin path: Caddy reverse-proxies /api/v1/* to the API container.
VITE_API_BASE_URL=/api/v1
# Google Tag Manager container ID (e.g. GTM-XXXXXXX)
VITE_GTM_ID=
# CMP site ID for dog-fooding our own consent banner
VITE_CMP_SITE_ID=

24
apps/admin-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

11
apps/admin-ui/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx vite build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

73
apps/admin-ui/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

43
apps/admin-ui/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B3C7C" />
<title>ConsentOS</title>
<!-- Google Consent Mode defaults — deny all until ConsentOS banner collects consent -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted',
});
</script>
<!-- Google Tag Manager -->
<script>
(function(w,d,s,l,i){if(!i)return;w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','%VITE_GTM_ID%');
</script>
<!-- Dog-fooding our own consent banner -->
<script src="/banner/consent-loader.js"
data-site-id="%VITE_CONSENTOS_SITE_ID%"
data-api-base="/api/v1"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/admin-ui/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
# Uses Docker's embedded DNS with a variable so nginx resolves at request
# time rather than at startup — prevents crash if api is temporarily down.
location /api/ {
resolver 127.0.0.11 valid=10s;
set $upstream http://api:8000;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

5948
apps/admin-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
{
"name": "@consentos/admin-ui",
"private": true,
"license": "Elastic-2.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "bash scripts/copy-banner.sh",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -b"
},
"dependencies": {
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/sora": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-router-dom": "^6.30.3",
"recharts": "^3.8.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,26 @@
# Cloudflare Pages custom headers
# https://developers.cloudflare.com/pages/configuration/headers/
/consent-loader.js
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
Cache-Control: public, max-age=3600
/consent-bundle.js
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
Cache-Control: public, max-age=3600
/consent-bundle.js.map
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
/site-config-*.json
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
Cache-Control: public, max-age=300
/translations-*.json
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
Cache-Control: public, max-age=300

View File

@@ -0,0 +1,5 @@
# Cloudflare Pages redirects
# https://developers.cloudflare.com/pages/configuration/redirects/
# SPA fallback — must be LAST so static files (banner scripts, config JSON) are served directly
/* /index.html 200

View File

@@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="11" fill="#1B3C7C"/>
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,7 @@
<svg width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="48" height="48" rx="11" fill="#1B3C7C"/>
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
<text x="62" y="32" font-family="Sora, system-ui, sans-serif" font-size="24" font-weight="600" fill="#1B3C7C" letter-spacing="-0.24">Consent</text>
<text x="159" y="32" font-family="Sora, system-ui, sans-serif" font-size="24" font-weight="600" fill="#2C6AE4" letter-spacing="-0.24">OS</text>
</svg>

After

Width:  |  Height:  |  Size: 645 B

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="11" fill="#1B3C7C"/>
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Ensure banner scripts exist in public/ before the admin-ui Vite build.
# If the banner hasn't been built yet, build it first.
set -euo pipefail
BANNER_DIR="../banner"
BANNER_DIST="$BANNER_DIR/dist"
if [ ! -f "$BANNER_DIST/consent-loader.js" ]; then
echo "[prebuild] Banner not built yet — building now..."
(cd "$BANNER_DIR" && npm ci && npm run build)
fi
echo "[prebuild] Copying banner scripts to public/"
mkdir -p public
cp "$BANNER_DIST/consent-loader.js" public/
cp "$BANNER_DIST/consent-bundle.js" public/
[ -f "$BANNER_DIST/consent-bundle.js.map" ] && cp "$BANNER_DIST/consent-bundle.js.map" public/
echo "[prebuild] Done — $(ls public/consent-loader.js)"

90
apps/admin-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation,
} from 'react-router-dom';
import Layout from './components/Layout';
import { trackPageView } from './services/analytics';
import ProtectedRoute from './components/ProtectedRoute';
import ComplianceDashboardPage from './pages/ComplianceDashboardPage';
import LoginPage from './pages/LoginPage';
import SettingsPage from './pages/SettingsPage';
import SiteDetailPage from './pages/SiteDetailPage';
import SiteGroupDetailPage from './pages/SiteGroupDetailPage';
import SitesPage from './pages/SitesPage';
import { useAuthStore } from './stores/auth';
import { discoverExtensions, getPages } from './extensions/registry';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
function AppRoutes() {
const { loadUser, isAuthenticated } = useAuthStore();
const location = useLocation();
const [extensionsReady, setExtensionsReady] = useState(false);
useEffect(() => {
loadUser();
discoverExtensions().then(() => setExtensionsReady(true));
}, [loadUser]);
useEffect(() => {
trackPageView(location.pathname);
}, [location.pathname]);
const extensionPages = extensionsReady ? getPages() : [];
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route path="/sites" element={<SitesPage />} />
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
<Route path="/compliance" element={<ComplianceDashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
{extensionPages
.filter((p) => p.protected !== false)
.map((p) => (
<Route key={p.path} path={p.path} element={<p.component />} />
))}
</Route>
{extensionPages
.filter((p) => p.protected === false)
.map((p) => (
<Route key={p.path} path={p.path} element={<p.component />} />
))}
<Route
path="*"
element={<Navigate to={isAuthenticated ? '/sites' : '/login'} replace />}
/>
</Routes>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,19 @@
import type { TokenResponse, User } from '../types/api';
import apiClient from './client';
export async function login(email: string, password: string): Promise<TokenResponse> {
const { data } = await apiClient.post<TokenResponse>('/auth/login', { email, password });
return data;
}
export async function refreshToken(token: string): Promise<TokenResponse> {
const { data } = await apiClient.post<TokenResponse>('/auth/refresh', {
refresh_token: token,
});
return data;
}
export async function getMe(): Promise<User> {
const { data } = await apiClient.get<User>('/auth/me');
return data;
}

View File

@@ -0,0 +1,121 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const apiClient = axios.create({
baseURL: API_BASE,
headers: { 'Content-Type': 'application/json' },
});
// ── Token storage helpers ──────────────────────────────────────────
function getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
function setAccessToken(token: string): void {
localStorage.setItem('access_token', token);
}
function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token');
}
function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token);
}
function clearTokens(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
// ── Request interceptor: attach bearer token ───────────────────────
apiClient.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ── Response interceptor: refresh-on-401 ──────────────────────────
/**
* When a request fails with 401, transparently attempt a single token
* refresh using the stored refresh token. Concurrent 401s share the
* same refresh promise so we don't hit ``/auth/refresh`` in parallel.
*
* If the refresh itself fails (no stored token, 401, or any other
* error), clear stored tokens and redirect to the login page.
*/
type RetryableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
let refreshPromise: Promise<string> | null = null;
async function performRefresh(): Promise<string> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Use a bare axios call so we don't re-enter this interceptor.
const { data } = await axios.post<{ access_token: string; refresh_token: string }>(
`${API_BASE}/auth/refresh`,
{ refresh_token: refreshToken },
{ headers: { 'Content-Type': 'application/json' } },
);
setAccessToken(data.access_token);
setRefreshToken(data.refresh_token);
return data.access_token;
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetryableRequest | undefined;
const status = error.response?.status;
// Not a 401, or we've already retried — give up and propagate.
if (status !== 401 || !original || original._retry) {
if (status === 401) {
clearTokens();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
}
// Don't loop on the refresh endpoint itself.
if (original.url?.includes('/auth/refresh')) {
clearTokens();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(error);
}
original._retry = true;
try {
// Coalesce concurrent refresh attempts.
refreshPromise = refreshPromise ?? performRefresh();
const newAccess = await refreshPromise;
refreshPromise = null;
original.headers = original.headers ?? {};
original.headers.Authorization = `Bearer ${newAccess}`;
return apiClient.request(original);
} catch (refreshError) {
refreshPromise = null;
clearTokens();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(
refreshError instanceof Error ? refreshError : new Error(String(refreshError)),
);
}
},
);
export default apiClient;

View File

@@ -0,0 +1,37 @@
import type {
ComplianceScoreSummary,
ComplianceScoreTrendResponse,
ValidationResultResponse,
} from '../types/api';
import apiClient from './client';
export async function getComplianceScoreSummary(
siteId: string,
): Promise<ComplianceScoreSummary> {
const { data } = await apiClient.get<ComplianceScoreSummary>(
`/sites/${siteId}/compliance-scores`,
);
return data;
}
export async function getComplianceScoreTrend(
siteId: string,
params?: { framework?: string; days?: number },
): Promise<ComplianceScoreTrendResponse> {
const { data } = await apiClient.get<ComplianceScoreTrendResponse>(
`/sites/${siteId}/compliance-scores/trend`,
{ params },
);
return data;
}
export async function triggerConsentValidation(
siteId: string,
url?: string,
): Promise<ValidationResultResponse> {
const { data } = await apiClient.post<ValidationResultResponse>(
`/sites/${siteId}/validate-consent`,
url ? { url } : null,
);
return data;
}

View File

@@ -0,0 +1,18 @@
import type { ComplianceCheckResponse, ComplianceFramework } from '../types/api';
import apiClient from './client';
export async function runComplianceCheck(
siteId: string,
frameworks?: ComplianceFramework[],
): Promise<ComplianceCheckResponse> {
const { data } = await apiClient.post<ComplianceCheckResponse>(
`/compliance/check/${siteId}`,
frameworks ? { frameworks } : {},
);
return data;
}
export async function listFrameworks(): Promise<{ frameworks: ComplianceFramework[] }> {
const { data } = await apiClient.get<{ frameworks: ComplianceFramework[] }>('/compliance/frameworks');
return data;
}

View File

@@ -0,0 +1,48 @@
import type { AllowListEntry, Cookie, CookieCategory } from '../types/api';
import apiClient from './client';
export async function listCategories(): Promise<CookieCategory[]> {
const { data } = await apiClient.get<CookieCategory[]>('/cookies/categories');
return data;
}
export async function listCookies(
siteId: string,
params?: { review_status?: string; category_id?: string },
): Promise<Cookie[]> {
const { data } = await apiClient.get<Cookie[]>(`/cookies/sites/${siteId}`, { params });
return data;
}
export async function updateCookie(
siteId: string,
cookieId: string,
body: Partial<Cookie>,
): Promise<Cookie> {
const { data } = await apiClient.patch<Cookie>(`/cookies/sites/${siteId}/${cookieId}`, body);
return data;
}
export async function getCookieSummary(
siteId: string,
): Promise<{ total: number; by_status: Record<string, number>; by_category: Record<string, number>; uncategorised: number }> {
const { data } = await apiClient.get(`/cookies/sites/${siteId}/summary`);
return data as { total: number; by_status: Record<string, number>; by_category: Record<string, number>; uncategorised: number };
}
export async function listAllowList(siteId: string): Promise<AllowListEntry[]> {
const { data } = await apiClient.get<AllowListEntry[]>(`/cookies/sites/${siteId}/allow-list`);
return data;
}
export async function createAllowListEntry(
siteId: string,
body: { name_pattern: string; domain_pattern: string; category_id: string; description?: string },
): Promise<AllowListEntry> {
const { data } = await apiClient.post<AllowListEntry>(`/cookies/sites/${siteId}/allow-list`, body);
return data;
}
export async function deleteAllowListEntry(siteId: string, entryId: string): Promise<void> {
await apiClient.delete(`/cookies/sites/${siteId}/allow-list/${entryId}`);
}

View File

@@ -0,0 +1,12 @@
import type { OrgConfig } from '../types/api';
import apiClient from './client';
export async function getOrgConfig(): Promise<OrgConfig> {
const { data } = await apiClient.get<OrgConfig>('/org-config/');
return data;
}
export async function updateOrgConfig(body: Partial<OrgConfig>): Promise<OrgConfig> {
const { data } = await apiClient.put<OrgConfig>('/org-config/', body);
return data;
}

View File

@@ -0,0 +1,31 @@
import type { ScanDiff, ScanJob, ScanJobDetail } from '../types/api';
import apiClient from './client';
export async function triggerScan(
siteId: string,
maxPages: number = 50,
): Promise<ScanJob> {
const { data } = await apiClient.post<ScanJob>('/scanner/scans', {
site_id: siteId,
max_pages: maxPages,
});
return data;
}
export async function listScans(
siteId: string,
params?: { limit?: number; offset?: number },
): Promise<ScanJob[]> {
const { data } = await apiClient.get<ScanJob[]>(`/scanner/scans/site/${siteId}`, { params });
return data;
}
export async function getScan(scanId: string): Promise<ScanJobDetail> {
const { data } = await apiClient.get<ScanJobDetail>(`/scanner/scans/${scanId}`);
return data;
}
export async function getScanDiff(scanId: string): Promise<ScanDiff> {
const { data } = await apiClient.get<ScanDiff>(`/scanner/scans/${scanId}/diff`);
return data;
}

View File

@@ -0,0 +1,18 @@
import type { SiteGroupConfig } from '../types/api';
import apiClient from './client';
export async function getSiteGroupConfig(groupId: string): Promise<SiteGroupConfig> {
const { data } = await apiClient.get<SiteGroupConfig>(`/site-groups/${groupId}/config`);
return data;
}
export async function updateSiteGroupConfig(
groupId: string,
body: Partial<SiteGroupConfig>,
): Promise<SiteGroupConfig> {
const { data } = await apiClient.put<SiteGroupConfig>(
`/site-groups/${groupId}/config`,
body,
);
return data;
}

View File

@@ -0,0 +1,32 @@
import type { SiteGroup } from '../types/api';
import apiClient from './client';
export async function listSiteGroups(): Promise<SiteGroup[]> {
const { data } = await apiClient.get<SiteGroup[]>('/site-groups/');
return data;
}
export async function getSiteGroup(id: string): Promise<SiteGroup> {
const { data } = await apiClient.get<SiteGroup>(`/site-groups/${id}`);
return data;
}
export async function createSiteGroup(body: {
name: string;
description?: string;
}): Promise<SiteGroup> {
const { data } = await apiClient.post<SiteGroup>('/site-groups/', body);
return data;
}
export async function updateSiteGroup(
id: string,
body: { name?: string; description?: string },
): Promise<SiteGroup> {
const { data } = await apiClient.patch<SiteGroup>(`/site-groups/${id}`, body);
return data;
}
export async function deleteSiteGroup(id: string): Promise<void> {
await apiClient.delete(`/site-groups/${id}`);
}

View File

@@ -0,0 +1,50 @@
import type { ConfigInheritanceResponse, Site, SiteConfig } from '../types/api';
import apiClient from './client';
export async function listSites(): Promise<Site[]> {
const { data } = await apiClient.get<Site[]>('/sites/');
return data;
}
export async function getSite(id: string): Promise<Site> {
const { data } = await apiClient.get<Site>(`/sites/${id}`);
return data;
}
export async function createSite(body: {
domain: string;
display_name: string;
site_group_id?: string;
}): Promise<Site> {
const { data } = await apiClient.post<Site>('/sites/', body);
return data;
}
export async function updateSite(id: string, body: Partial<Site>): Promise<Site> {
const { data } = await apiClient.patch<Site>(`/sites/${id}`, body);
return data;
}
export async function deleteSite(id: string): Promise<void> {
await apiClient.delete(`/sites/${id}`);
}
export async function getSiteConfig(siteId: string): Promise<SiteConfig> {
const { data } = await apiClient.get<SiteConfig>(`/sites/${siteId}/config`);
return data;
}
export async function updateSiteConfig(
siteId: string,
body: Partial<SiteConfig>,
): Promise<SiteConfig> {
const { data } = await apiClient.put<SiteConfig>(`/sites/${siteId}/config`, body);
return data;
}
export async function getConfigInheritance(siteId: string): Promise<ConfigInheritanceResponse> {
const { data } = await apiClient.get<ConfigInheritanceResponse>(
`/config/sites/${siteId}/inheritance`,
);
return data;
}

View File

@@ -0,0 +1,36 @@
import type { Translation } from '../types/api';
import apiClient from './client';
export async function listTranslations(siteId: string): Promise<Translation[]> {
const { data } = await apiClient.get<Translation[]>(`/sites/${siteId}/translations/`);
return data;
}
export async function getTranslation(siteId: string, locale: string): Promise<Translation> {
const { data } = await apiClient.get<Translation>(`/sites/${siteId}/translations/${locale}`);
return data;
}
export async function createTranslation(
siteId: string,
body: { locale: string; strings: Record<string, string> },
): Promise<Translation> {
const { data } = await apiClient.post<Translation>(`/sites/${siteId}/translations/`, body);
return data;
}
export async function updateTranslation(
siteId: string,
locale: string,
body: { strings: Record<string, string> },
): Promise<Translation> {
const { data } = await apiClient.put<Translation>(
`/sites/${siteId}/translations/${locale}`,
body,
);
return data;
}
export async function deleteTranslation(siteId: string, locale: string): Promise<void> {
await apiClient.delete(`/sites/${siteId}/translations/${locale}`);
}

View File

@@ -0,0 +1,476 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { trackConfigChange } from '../services/analytics';
import type { BannerConfig, ButtonConfig } from '../types/api';
import { Button } from './ui/button.tsx';
import { Card, CardContent } from './ui/card.tsx';
import { Alert } from './ui/alert.tsx';
import { Select } from './ui/select.tsx';
import { TabGroup } from './ui/tab-group.tsx';
import BannerPreview from './BannerPreview';
type DisplayMode = 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
type CornerPosition = 'left' | 'right';
type Viewport = 'desktop' | 'mobile';
const DISPLAY_MODES: { value: DisplayMode; label: string }[] = [
{ value: 'bottom_banner', label: 'Bottom banner' },
{ value: 'top_banner', label: 'Top banner' },
{ value: 'overlay', label: 'Overlay (modal)' },
{ value: 'corner_popup', label: 'Corner popup' },
];
const FONT_OPTIONS = [
{ value: 'system-ui', label: 'System default' },
{ value: "'Inter', sans-serif", label: 'Inter' },
{ value: "'Roboto', sans-serif", label: 'Roboto' },
{ value: "'Open Sans', sans-serif", label: 'Open Sans' },
{ value: "'Lato', sans-serif", label: 'Lato' },
{ value: "Georgia, serif", label: 'Georgia (serif)' },
];
interface Props {
/** Unique key for cache invalidation (e.g. ['sites', siteId, 'config'] or ['org-config']) */
configQueryKey: string[];
/** The config object containing banner_config */
config: { banner_config: BannerConfig | null } | null;
/** Function to save the updated banner config */
onSave: (body: { banner_config: BannerConfig }) => Promise<unknown>;
/** Optional domain for the preview iframe */
siteDomain?: string | null;
}
interface Defaults {
primaryColour: string;
backgroundColour: string;
textColour: string;
buttonStyle: 'filled' | 'outline';
fontFamily: string;
borderRadius: number;
showRejectAll: boolean;
showManagePreferences: boolean;
showCloseButton: boolean;
showLogo: boolean;
logoUrl: string;
showCookieCount: boolean;
displayMode: DisplayMode;
cornerPosition: CornerPosition;
acceptButton: ButtonConfig;
rejectButton: ButtonConfig;
manageButton: ButtonConfig;
}
function getDefaults(config: { banner_config: BannerConfig | null } | null): Defaults {
const bc = config?.banner_config;
return {
primaryColour: bc?.primaryColour ?? '#2563eb',
backgroundColour: bc?.backgroundColour ?? '#ffffff',
textColour: bc?.textColour ?? '#1a1a2e',
buttonStyle: bc?.buttonStyle ?? 'filled',
fontFamily: bc?.fontFamily ?? 'system-ui',
borderRadius: bc?.borderRadius ?? 6,
showRejectAll: bc?.showRejectAll ?? true,
showManagePreferences: bc?.showManagePreferences ?? true,
showCloseButton: bc?.showCloseButton ?? false,
showLogo: bc?.showLogo ?? false,
logoUrl: bc?.logoUrl ?? '',
showCookieCount: bc?.showCookieCount ?? false,
displayMode: (bc?.displayMode as DisplayMode) ?? 'bottom_banner',
cornerPosition: (bc?.cornerPosition as CornerPosition) ?? 'right',
acceptButton: bc?.acceptButton ?? {},
rejectButton: bc?.rejectButton ?? {},
manageButton: bc?.manageButton ?? {},
};
}
export default function BannerBuilderTab({ configQueryKey, config, onSave, siteDomain }: Props) {
const queryClient = useQueryClient();
const defaults = useMemo(() => getDefaults(config), [config]);
// Theme state
const [primaryColour, setPrimaryColour] = useState(defaults.primaryColour);
const [backgroundColour, setBackgroundColour] = useState(defaults.backgroundColour);
const [textColour, setTextColour] = useState(defaults.textColour);
const [buttonStyle, setButtonStyle] = useState(defaults.buttonStyle);
const [fontFamily, setFontFamily] = useState(defaults.fontFamily);
const [borderRadius, setBorderRadius] = useState(defaults.borderRadius);
// Layout state
const [showRejectAll, setShowRejectAll] = useState(defaults.showRejectAll);
const [showManagePreferences, setShowManagePreferences] = useState(defaults.showManagePreferences);
const [showCloseButton, setShowCloseButton] = useState(defaults.showCloseButton);
const [showLogo, setShowLogo] = useState(defaults.showLogo);
const [logoUrl, setLogoUrl] = useState(defaults.logoUrl);
const [showCookieCount, setShowCookieCount] = useState(defaults.showCookieCount);
// Display mode and viewport
const [displayMode, setDisplayMode] = useState<DisplayMode>(defaults.displayMode);
const [cornerPosition, setCornerPosition] = useState<CornerPosition>(defaults.cornerPosition);
const [viewport, setViewport] = useState<Viewport>('desktop');
// Per-button styling
const [acceptButton, setAcceptButton] = useState<ButtonConfig>(defaults.acceptButton);
const [rejectButton, setRejectButton] = useState<ButtonConfig>(defaults.rejectButton);
const [manageButton, setManageButton] = useState<ButtonConfig>(defaults.manageButton);
const [saved, setSaved] = useState(false);
const mutation = useMutation({
mutationFn: (body: { banner_config: BannerConfig }) => onSave(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: configQueryKey });
trackConfigChange('banner_config');
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const bannerConfig: BannerConfig = useMemo(
() => ({
primaryColour,
backgroundColour,
textColour,
buttonStyle,
fontFamily,
borderRadius,
showRejectAll,
showManagePreferences,
showCloseButton,
showLogo,
logoUrl: logoUrl || undefined,
showCookieCount,
cornerPosition,
acceptButton: Object.keys(acceptButton).length > 0 ? acceptButton : undefined,
rejectButton: Object.keys(rejectButton).length > 0 ? rejectButton : undefined,
manageButton: Object.keys(manageButton).length > 0 ? manageButton : undefined,
}),
[
primaryColour, backgroundColour, textColour, buttonStyle, fontFamily,
borderRadius, showRejectAll, showManagePreferences, showCloseButton,
showLogo, logoUrl, showCookieCount, cornerPosition,
acceptButton, rejectButton, manageButton,
],
);
const handleSave = useCallback(() => {
mutation.mutate({
banner_config: { ...bannerConfig, displayMode },
});
}, [mutation, bannerConfig, displayMode]);
return (
<div className="flex gap-6" data-testid="banner-builder">
{/* Left panel — controls */}
<div className="w-80 shrink-0 space-y-5 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 200px)' }}>
{/* Display mode */}
<Card>
<CardContent className="p-5">
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Display mode</h3>
<div className="grid grid-cols-2 gap-2">
{DISPLAY_MODES.map((mode) => (
<button
key={mode.value}
onClick={() => setDisplayMode(mode.value)}
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
displayMode === mode.value
? 'bg-primary text-primary-foreground'
: 'bg-mist text-text-secondary hover:bg-mist/80'
}`}
>
{mode.label}
</button>
))}
</div>
{/* Corner position — only shown for corner_popup */}
{displayMode === 'corner_popup' && (
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-text-secondary">Position</label>
<div className="flex gap-2">
{(['left', 'right'] as const).map((pos) => (
<button
key={pos}
onClick={() => setCornerPosition(pos)}
className={`flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition ${
cornerPosition === pos
? 'bg-primary text-primary-foreground'
: 'bg-mist text-text-secondary hover:bg-mist/80'
}`}
>
{pos.charAt(0).toUpperCase() + pos.slice(1)}
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Theme */}
<Card>
<CardContent className="p-5">
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Theme</h3>
<div className="space-y-3">
<ColourField label="Primary colour" value={primaryColour} onChange={setPrimaryColour} />
<ColourField label="Background" value={backgroundColour} onChange={setBackgroundColour} />
<ColourField label="Text colour" value={textColour} onChange={setTextColour} />
<div>
<label className="mb-1 block text-xs font-medium text-text-secondary">Font</label>
<Select
value={fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-text-secondary">
Border radius ({borderRadius}px)
</label>
<input
type="range"
min={0}
max={20}
value={borderRadius}
onChange={(e) => setBorderRadius(Number(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-text-secondary">Default button style</label>
<div className="flex gap-2">
{(['filled', 'outline'] as const).map((style) => (
<button
key={style}
onClick={() => setButtonStyle(style)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
buttonStyle === style
? 'bg-primary text-primary-foreground'
: 'bg-mist text-text-secondary hover:bg-mist/80'
}`}
>
{style.charAt(0).toUpperCase() + style.slice(1)}
</button>
))}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Button styling */}
<Card>
<CardContent className="p-5">
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Button styling</h3>
<p className="mb-3 text-xs text-text-secondary">
Override colours per button, or leave blank to use the theme defaults.
</p>
<div className="space-y-4">
<ButtonStyleEditor
label="Accept button"
config={acceptButton}
onChange={setAcceptButton}
defaults={{ backgroundColour: primaryColour, textColour: '#ffffff', style: buttonStyle }}
/>
{showRejectAll && (
<ButtonStyleEditor
label="Reject button"
config={rejectButton}
onChange={setRejectButton}
defaults={{ backgroundColour: 'transparent', textColour, style: 'outline' }}
/>
)}
{showManagePreferences && (
<ButtonStyleEditor
label="Manage preferences"
config={manageButton}
onChange={setManageButton}
defaults={{ backgroundColour: 'transparent', textColour, style: 'outline' }}
/>
)}
</div>
</CardContent>
</Card>
{/* Layout */}
<Card>
<CardContent className="p-5">
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Layout</h3>
<div className="space-y-2.5">
<ToggleField label="Show 'Reject all' button" checked={showRejectAll} onChange={setShowRejectAll} />
<ToggleField label="Show 'Manage preferences'" checked={showManagePreferences} onChange={setShowManagePreferences} />
<ToggleField label="Show close button" checked={showCloseButton} onChange={setShowCloseButton} />
<ToggleField label="Show cookie count" checked={showCookieCount} onChange={setShowCookieCount} />
<ToggleField label="Show logo" checked={showLogo} onChange={setShowLogo} />
{showLogo && (
<div>
<label className="mb-1 block text-xs font-medium text-text-secondary">Logo URL</label>
<input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.svg"
className="w-full rounded-lg border border-border px-3 py-1.5 text-sm"
/>
</div>
)}
</div>
</CardContent>
</Card>
{/* Save */}
<div className="flex items-center gap-3">
<Button
onClick={handleSave}
disabled={mutation.isPending}
className="w-full"
>
{mutation.isPending ? 'Saving...' : 'Save banner'}
</Button>
</div>
{saved && <Alert variant="success">Saved successfully</Alert>}
{mutation.isError && <Alert variant="error">Failed to save. Please try again.</Alert>}
</div>
{/* Right panel — preview */}
<div className="flex-1">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-heading text-sm font-semibold text-foreground">Live preview</h3>
<TabGroup
options={[
{ value: 'desktop', label: 'Desktop' },
{ value: 'mobile', label: 'Mobile' },
]}
value={viewport}
onChange={(v) => setViewport(v as Viewport)}
/>
</div>
<BannerPreview
bannerConfig={bannerConfig}
displayMode={displayMode}
cornerPosition={cornerPosition}
viewport={viewport}
privacyPolicyUrl={(config as Record<string, unknown>)?.privacy_policy_url as string ?? null}
siteUrl={siteDomain}
/>
</div>
</div>
);
}
/* ── Helper components ─────────────────────────────────────────────── */
function ColourField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex items-center gap-3">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border border-border"
/>
<div className="flex-1">
<label className="block text-xs font-medium text-text-secondary">{label}</label>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-border px-2 py-0.5 text-xs font-mono text-text-secondary"
/>
</div>
</div>
);
}
function ToggleField({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center justify-between">
<span className="text-sm text-text-secondary">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-border text-copper"
/>
</label>
);
}
function ButtonStyleEditor({
label,
config,
onChange,
defaults,
}: {
label: string;
config: ButtonConfig;
onChange: (c: ButtonConfig) => void;
defaults: { backgroundColour: string; textColour: string; style: string };
}) {
const update = (patch: Partial<ButtonConfig>) => onChange({ ...config, ...patch });
const bgColour = config.backgroundColour ?? defaults.backgroundColour;
const txtColour = config.textColour ?? defaults.textColour;
const style = config.style ?? defaults.style;
return (
<div className="rounded-lg border border-border p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">{label}</p>
<div className="space-y-2">
<div className="flex gap-2">
{(['filled', 'outline', 'text'] as const).map((s) => (
<button
key={s}
onClick={() => update({ style: s })}
className={`rounded px-2 py-1 text-xs font-medium transition ${
style === s
? 'bg-primary text-primary-foreground'
: 'bg-mist text-text-secondary hover:bg-mist/80'
}`}
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="color"
value={bgColour}
onChange={(e) => update({ backgroundColour: e.target.value })}
className="h-6 w-6 cursor-pointer rounded border border-border"
/>
<span className="text-xs text-text-secondary">Background</span>
<input
type="color"
value={txtColour}
onChange={(e) => update({ textColour: e.target.value })}
className="ml-auto h-6 w-6 cursor-pointer rounded border border-border"
/>
<span className="text-xs text-text-secondary">Text</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BannerConfig, ButtonConfig } from '../types/api';
type DisplayMode = 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
type CornerPosition = 'left' | 'right';
type Viewport = 'desktop' | 'mobile';
/* ── Default text values ─────────────────────────────────────────────── */
const DEFAULT_TITLE = 'We use cookies';
const DEFAULT_DESCRIPTION =
'We use cookies and similar technologies to enhance your browsing experience, ' +
'analyse site traffic, and personalise content. You can choose which categories to allow.';
const DEFAULT_ACCEPT_ALL = 'Accept all';
const DEFAULT_REJECT_ALL = 'Reject all';
const DEFAULT_MANAGE_PREFERENCES = 'Manage preferences';
const DEFAULT_SAVE_PREFERENCES = 'Save preferences';
interface Props {
bannerConfig: BannerConfig;
displayMode: DisplayMode;
cornerPosition?: CornerPosition;
viewport: Viewport;
privacyPolicyUrl: string | null;
siteUrl?: string | null;
previewLocale?: string;
}
export default function BannerPreview({
bannerConfig,
displayMode,
cornerPosition = 'right',
viewport,
privacyPolicyUrl,
siteUrl,
previewLocale,
}: Props) {
const [iframeLoadFailed, setIframeLoadFailed] = useState(false);
const [iframeLoaded, setIframeLoaded] = useState(false);
const siteIframeRef = useRef<HTMLIFrameElement>(null);
const bannerSrcdoc = useMemo(
() => buildBannerOnlyHtml(bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale),
[bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale],
);
const fallbackSrcdoc = useMemo(
() => buildPreviewHtml(bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale),
[bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale],
);
const fullSiteUrl = useMemo(() => {
if (!siteUrl) return null;
// Ensure the URL has a protocol
if (siteUrl.startsWith('http://') || siteUrl.startsWith('https://')) return siteUrl;
return `https://${siteUrl}`;
}, [siteUrl]);
// Reset state when the site URL changes
useEffect(() => {
setIframeLoadFailed(false);
setIframeLoaded(false);
}, [fullSiteUrl]);
const handleSiteIframeLoad = useCallback(() => {
// Check if the iframe actually loaded content by trying to access it
// If X-Frame-Options or CSP blocks it, the iframe will be blank
const iframe = siteIframeRef.current;
if (!iframe) return;
try {
// Try to detect if the iframe loaded — accessing contentDocument will throw
// for cross-origin frames, but that's fine (it means it loaded)
// If the iframe is blank/error, some browsers fire load anyway
const doc = iframe.contentDocument;
if (doc && doc.body && doc.body.innerHTML === '') {
// Empty body might mean it was blocked
setIframeLoadFailed(true);
} else {
setIframeLoaded(true);
}
} catch {
// Cross-origin — means the site loaded successfully
setIframeLoaded(true);
}
}, []);
const handleSiteIframeError = useCallback(() => {
setIframeLoadFailed(true);
}, []);
const width = viewport === 'mobile' ? 375 : '100%';
const useLiveSite = fullSiteUrl && !iframeLoadFailed;
return (
<div
className="relative overflow-hidden rounded-lg border border-border bg-mist"
style={{ height: 500 }}
data-testid="banner-preview"
>
{useLiveSite ? (
<>
{/* Live site iframe (background) */}
<iframe
ref={siteIframeRef}
src={fullSiteUrl}
title="Site preview"
sandbox="allow-scripts allow-same-origin"
onLoad={handleSiteIframeLoad}
onError={handleSiteIframeError}
style={{
width,
height: '100%',
border: 'none',
margin: viewport === 'mobile' ? '0 auto' : undefined,
display: 'block',
transition: 'width 0.3s ease',
opacity: iframeLoaded ? 1 : 0.3,
}}
/>
{/* Banner overlay on top of the live site */}
<iframe
srcDoc={bannerSrcdoc}
sandbox="allow-scripts"
title="Banner preview"
style={{
position: 'absolute',
inset: 0,
width: viewport === 'mobile' ? 375 : '100%',
height: '100%',
border: 'none',
margin: viewport === 'mobile' ? '0 auto' : undefined,
pointerEvents: 'none',
background: 'transparent',
}}
/>
{!iframeLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-mist/80">
<p className="text-sm text-text-secondary">Loading site preview</p>
</div>
)}
</>
) : (
/* Fallback: self-contained preview with placeholder content */
<iframe
srcDoc={fallbackSrcdoc}
sandbox="allow-scripts"
title="Banner preview"
style={{
width,
height: '100%',
border: 'none',
margin: viewport === 'mobile' ? '0 auto' : undefined,
display: 'block',
transition: 'width 0.3s ease',
}}
/>
)}
{iframeLoadFailed && fullSiteUrl && (
<div className="absolute bottom-2 left-2 rounded bg-status-warning-bg px-2 py-1 text-xs text-status-warning-fg ring-1 ring-status-warning-fg/20">
Could not load site preview the site may block iframe embedding
</div>
)}
</div>
);
}
/* ── Banner-only HTML (transparent background, overlay on live site) ── */
function buildBannerOnlyHtml(
bc: BannerConfig,
displayMode: DisplayMode,
cornerPosition: CornerPosition,
privacyUrl: string | null,
previewLocale?: string,
): string {
const bg = bc.backgroundColour ?? '#ffffff';
const text = bc.textColour ?? '#1a1a2e';
const primary = bc.primaryColour ?? '#2563eb';
const font = bc.fontFamily ?? 'system-ui';
const radius = bc.borderRadius ?? 6;
const defaultButtonStyle = bc.buttonStyle ?? 'filled';
const positionStyles = getPositionStyles(displayMode, cornerPosition, radius);
const { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText } =
buildBannerParts(bc, primary, text, radius, privacyUrl, defaultButtonStyle);
const fontLink = bc.customFontUrl
? `<link rel="stylesheet" href="${escapeHtml(bc.customFontUrl)}">`
: '';
const langAttr = previewLocale ? escapeHtml(previewLocale) : 'en';
return `<!DOCTYPE html>
<html lang="${langAttr}">
<head>
<meta charset="utf-8">
${fontLink}
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: transparent; }
.consentos-banner {
${positionStyles}
background: ${bg};
color: ${text};
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: ${displayMode === 'overlay' || displayMode === 'corner_popup' ? radius + 'px' : '0'};
pointer-events: auto;
}
.consentos-banner__content {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
position: relative;
}
.cmp-logo { height: 28px; margin-bottom: 10px; display: block; }
.consentos-banner__title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.consentos-banner__description { margin-bottom: 16px; opacity: 0.85; }
.consentos-banner__link { color: ${primary}; text-decoration: underline; }
.cmp-cookie-count { display: block; font-size: 12px; opacity: 0.6; margin-bottom: 12px; }
.consentos-banner__actions { display: flex; gap: 10px; flex-wrap: wrap; }
.cmp-btn {
padding: 10px 20px;
border-radius: ${radius}px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
}
.cmp-close {
position: absolute; top: 12px; right: 12px;
background: none; border: none; font-size: 22px;
cursor: pointer; color: ${text}; opacity: 0.5; line-height: 1;
}
.cmp-overlay-bg {
display: ${displayMode === 'overlay' ? 'block' : 'none'};
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 2147483646;
}
@media (max-width: 640px) {
.consentos-banner__actions { flex-direction: column; }
.cmp-btn { width: 100%; text-align: center; }
}
</style>
</head>
<body>
<div class="cmp-overlay-bg"></div>
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
<div class="consentos-banner__content">
${closeBtn}
${logoHtml}
<p class="consentos-banner__title">${escapeHtml(titleText)}</p>
<p class="consentos-banner__description">
${escapeHtml(descriptionText)}${privacyLink}
</p>
${cookieCount}
<div class="consentos-banner__actions">
${rejectBtn}
${manageBtn}
${acceptBtn}
</div>
</div>
</div>
</body>
</html>`;
}
/* ── Full preview HTML (with placeholder page content, used as fallback) ── */
function buildPreviewHtml(
bc: BannerConfig,
displayMode: DisplayMode,
cornerPosition: CornerPosition,
privacyUrl: string | null,
previewLocale?: string,
): string {
const bg = bc.backgroundColour ?? '#ffffff';
const text = bc.textColour ?? '#1a1a2e';
const primary = bc.primaryColour ?? '#2563eb';
const font = bc.fontFamily ?? 'system-ui';
const radius = bc.borderRadius ?? 6;
const defaultButtonStyle = bc.buttonStyle ?? 'filled';
const positionStyles = getPositionStyles(displayMode, cornerPosition, radius);
const { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText, savePreferencesText } =
buildBannerParts(bc, primary, text, radius, privacyUrl, defaultButtonStyle);
const fontLink = bc.customFontUrl
? `<link rel="stylesheet" href="${escapeHtml(bc.customFontUrl)}">`
: '';
const langAttr = previewLocale ? escapeHtml(previewLocale) : 'en';
// Build the save preferences button with accept button styling
const acceptStyle = buildButtonStyle(bc.acceptButton, defaultButtonStyle, primary, '#ffffff', 'none', radius);
const saveBtnHtml = `<button class="cmp-btn cmp-btn--primary cmp-btn--save" style="${acceptStyle}">${escapeHtml(savePreferencesText)}</button>`;
return `<!DOCTYPE html>
<html lang="${langAttr}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
${fontLink}
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #f3f4f6;
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.page-content {
padding: 32px 24px;
color: #6b7280;
font-size: 13px;
line-height: 1.8;
}
.page-content h2 { color: #374151; font-size: 18px; margin-bottom: 12px; }
.page-content p { margin-bottom: 12px; }
.consentos-banner {
${positionStyles}
background: ${bg};
color: ${text};
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: ${displayMode === 'overlay' || displayMode === 'corner_popup' ? radius + 'px' : '0'};
}
.consentos-banner__content {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
position: relative;
}
.cmp-logo { height: 28px; margin-bottom: 10px; display: block; }
.consentos-banner__title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.consentos-banner__description { margin-bottom: 16px; opacity: 0.85; }
.consentos-banner__link { color: ${primary}; text-decoration: underline; }
.cmp-cookie-count { display: block; font-size: 12px; opacity: 0.6; margin-bottom: 12px; }
.consentos-banner__actions { display: flex; gap: 10px; flex-wrap: wrap; }
.cmp-btn {
padding: 10px 20px;
border-radius: ${radius}px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
font-family: inherit;
}
.cmp-close {
position: absolute; top: 12px; right: 12px;
background: none; border: none; font-size: 22px;
cursor: pointer; color: ${text}; opacity: 0.5; line-height: 1;
}
.consentos-banner__categories { display: none; margin-bottom: 16px; }
.cmp-category {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.cmp-category__info { display: flex; flex-direction: column; flex: 1; margin-right: 12px; }
.cmp-category__name { font-weight: 500; }
.cmp-category__desc { font-size: 12px; opacity: 0.7; }
.cmp-category input[type="checkbox"] { width: 18px; height: 18px; accent-color: ${primary}; }
.cmp-btn--save { margin-top: 12px; width: 100%; }
.cmp-overlay-bg {
display: ${displayMode === 'overlay' ? 'block' : 'none'};
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 2147483646;
}
@media (max-width: 640px) {
.consentos-banner__actions { flex-direction: column; }
.cmp-btn { width: 100%; text-align: center; }
}
</style>
</head>
<body>
<div class="page-content">
<h2>Example page</h2>
<p>This is a preview of how the consent banner will appear on your site. The banner is rendered with your current theme and layout settings.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
</div>
<div class="cmp-overlay-bg"></div>
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
<div class="consentos-banner__content">
${closeBtn}
${logoHtml}
<p class="consentos-banner__title">${escapeHtml(titleText)}</p>
<p class="consentos-banner__description">
${escapeHtml(descriptionText)}${privacyLink}
</p>
${cookieCount}
<div class="consentos-banner__categories" id="cmp-prefs">
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name">Necessary</span>
<span class="cmp-category__desc">Essential for the website to function. Always active.</span>
</div>
<input type="checkbox" checked disabled />
</label>
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name">Functional</span>
<span class="cmp-category__desc">Enable enhanced functionality and personalisation.</span>
</div>
<input type="checkbox" />
</label>
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name">Analytics</span>
<span class="cmp-category__desc">Help us understand how visitors interact with the site.</span>
</div>
<input type="checkbox" />
</label>
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name">Marketing</span>
<span class="cmp-category__desc">Used to deliver personalised advertisements.</span>
</div>
<input type="checkbox" />
</label>
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name">Personalisation</span>
<span class="cmp-category__desc">Enable content personalisation based on your profile.</span>
</div>
<input type="checkbox" />
</label>
${saveBtnHtml}
</div>
<div class="consentos-banner__actions">
${rejectBtn}
${manageBtn}
${acceptBtn}
</div>
</div>
</div>
<script>
function togglePrefs() {
var el = document.getElementById('cmp-prefs');
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
</body>
</html>`;
}
/* ── Shared helpers ──────────────────────────────────────────────────── */
function buildButtonStyle(
config: ButtonConfig | undefined,
defaultStyle: 'filled' | 'outline',
fallbackBg: string,
fallbackText: string,
fallbackBorder: string,
radius: number,
): string {
const bg = config?.backgroundColour ?? fallbackBg;
const color = config?.textColour ?? fallbackText;
const style = config?.style ?? defaultStyle;
const border = config?.borderColour
? `1px solid ${config.borderColour}`
: style === 'outline'
? `1px solid ${config?.textColour ?? fallbackBorder}`
: style === 'text'
? 'none'
: fallbackBorder === 'none'
? 'none'
: `1px solid ${fallbackBorder}`;
const background = style === 'text' ? 'transparent' : style === 'outline' ? 'transparent' : bg;
return `background: ${background}; color: ${color}; border: ${border}; border-radius: ${radius}px;`;
}
function buildBannerParts(
bc: BannerConfig,
primary: string,
text: string,
radius: number,
privacyUrl: string | null,
defaultButtonStyle: 'filled' | 'outline',
) {
const acceptStyle = buildButtonStyle(bc.acceptButton, defaultButtonStyle, primary, '#ffffff', 'none', radius);
const rejectStyle = buildButtonStyle(bc.rejectButton, defaultButtonStyle, 'transparent', text, 'rgba(0,0,0,0.2)', radius);
const manageStyle = buildButtonStyle(bc.manageButton, defaultButtonStyle, 'transparent', text, 'rgba(0,0,0,0.2)', radius);
// Resolve text content from config or defaults
const titleText = bc.text?.title ?? DEFAULT_TITLE;
const descriptionText = bc.text?.description ?? DEFAULT_DESCRIPTION;
const acceptAllText = bc.text?.acceptAll ?? DEFAULT_ACCEPT_ALL;
const rejectAllText = bc.text?.rejectAll ?? DEFAULT_REJECT_ALL;
const managePreferencesText = bc.text?.managePreferences ?? DEFAULT_MANAGE_PREFERENCES;
const savePreferencesText = bc.text?.savePreferences ?? DEFAULT_SAVE_PREFERENCES;
const acceptBtn = `<button class="cmp-btn" style="${acceptStyle}">${escapeHtml(acceptAllText)}</button>`;
const rejectBtn = bc.showRejectAll !== false
? `<button class="cmp-btn" style="${rejectStyle}">${escapeHtml(rejectAllText)}</button>`
: '';
const manageBtn = bc.showManagePreferences !== false
? `<button class="cmp-btn" style="${manageStyle}" onclick="typeof togglePrefs==='function'&&togglePrefs()">${escapeHtml(managePreferencesText)}</button>`
: '';
const closeBtn = bc.showCloseButton
? `<button class="cmp-close" aria-label="Close">&times;</button>`
: '';
const logoHtml = bc.showLogo && bc.logoUrl
? `<img src="${escapeHtml(bc.logoUrl)}" alt="Logo" class="cmp-logo" />`
: '';
const cookieCount = bc.showCookieCount
? `<span class="cmp-cookie-count">12 cookies used on this site</span>`
: '';
const privacyLink = privacyUrl
? ` <a href="#" class="consentos-banner__link" onclick="return false">Privacy Policy</a>`
: '';
return { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText, savePreferencesText };
}
function getPositionStyles(mode: DisplayMode, cornerPosition: CornerPosition, radius: number): string {
switch (mode) {
case 'top_banner':
return 'position: fixed; top: 0; left: 0; right: 0; z-index: 2147483647;';
case 'overlay':
return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90%; max-width: 600px; border-radius: ${radius}px;`;
case 'corner_popup': {
const side = cornerPosition === 'left' ? 'left: 20px;' : 'right: 20px;';
return `position: fixed; bottom: 20px; ${side} z-index: 2147483647; width: 380px; max-width: calc(100% - 40px); border-radius: ${radius}px;`;
}
case 'bottom_banner':
default:
return 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483647;';
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,107 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { listSiteGroups } from '../api/site-groups';
import { createSite } from '../api/sites';
import { trackFeatureUsage } from '../services/analytics';
import { Modal } from './ui/modal.tsx';
import { FormField } from './ui/form-field.tsx';
import { Input } from './ui/input.tsx';
import { Select } from './ui/select.tsx';
import { Button } from './ui/button.tsx';
import { Alert } from './ui/alert.tsx';
interface Props {
onClose: () => void;
defaultGroupId?: string;
}
export default function CreateSiteModal({ onClose, defaultGroupId }: Props) {
const queryClient = useQueryClient();
const [domain, setDomain] = useState('');
const [name, setName] = useState('');
const [groupId, setGroupId] = useState(defaultGroupId ?? '');
const [error, setError] = useState('');
const { data: groups } = useQuery({
queryKey: ['site-groups'],
queryFn: listSiteGroups,
});
const mutation = useMutation({
mutationFn: createSite,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
trackFeatureUsage('site', 'create');
onClose();
},
onError: () => {
setError('Failed to create site. Check the domain is unique.');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setError('');
mutation.mutate({
domain,
display_name: name || domain,
site_group_id: groupId || undefined,
});
};
return (
<Modal open={true} onClose={onClose} title="Add site">
<form onSubmit={handleSubmit} className="space-y-4">
{error && <Alert variant="error">{error}</Alert>}
<FormField label="Domain">
<Input
id="domain"
type="text"
required
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="example.com"
/>
</FormField>
<FormField label="Display name (optional)">
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Website"
/>
</FormField>
<FormField label="Site group (optional)">
<Select
id="group"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
>
<option value="">No group</option>
{groups?.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create site'}
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,114 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
/**
* Top-level React error boundary.
*
* Catches unhandled rendering errors so a single bad component cannot
* take down the whole admin app. Falls back to a simple friendly
* panel with a reload button. The error is logged to the console for
* dev debugging; in production, wire this up to an error reporter.
*/
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// eslint-disable-next-line no-console
console.error('[ErrorBoundary] Unhandled error:', error, errorInfo);
}
handleReload = (): void => {
window.location.reload();
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div
role="alert"
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
fontFamily: 'system-ui, sans-serif',
background: '#fafafa',
}}
>
<div
style={{
maxWidth: '28rem',
background: '#fff',
padding: '2rem',
borderRadius: '0.5rem',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
}}
>
<h1 style={{ margin: '0 0 0.75rem', fontSize: '1.25rem', color: '#111' }}>
Something went wrong
</h1>
<p style={{ margin: '0 0 1.5rem', color: '#555', lineHeight: 1.5 }}>
An unexpected error occurred while rendering this page. The
error has been logged. Reload to try again.
</p>
{this.state.error?.message && (
<pre
style={{
background: '#f4f4f5',
padding: '0.75rem',
borderRadius: '0.375rem',
fontSize: '0.8rem',
color: '#7f1d1d',
overflow: 'auto',
marginBottom: '1.5rem',
whiteSpace: 'pre-wrap',
}}
>
{this.state.error.message}
</pre>
)}
<button
type="button"
onClick={this.handleReload}
style={{
padding: '0.5rem 1rem',
background: '#111',
color: '#fff',
border: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
cursor: 'pointer',
}}
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,141 @@
import { useMemo, useState } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
import { getNavItems } from '../extensions/registry';
const CORE_NAV_ITEMS = [
{ path: '/sites', label: 'Sites', order: 10 },
{ path: '/compliance', label: 'Compliance', order: 20 },
{ path: '/settings', label: 'Settings', order: 90 },
];
export default function Layout() {
const { user, logout } = useAuthStore();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const NAV_ITEMS = useMemo(() => {
const extensionItems = getNavItems().map((item) => ({
path: item.path,
label: item.label,
order: item.order ?? 200,
}));
return [...CORE_NAV_ITEMS, ...extensionItems].sort(
(a, b) => a.order - b.order,
);
}, []);
return (
<div className="min-h-screen bg-background">
{/* Top nav */}
<header className="sticky top-0 z-40 border-b border-border-subtle bg-card">
<div className="flex h-14 items-center justify-between px-4 md:px-6">
{/* Left: logo + desktop nav */}
<div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2 font-heading text-lg font-semibold text-foreground">
<img src="/logo-mark.svg" alt="" width="24" height="24" aria-hidden="true" />
<span>
<span className="text-primary">Consent</span>
<span className="text-action">OS</span>
</span>
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">
{NAV_ITEMS.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`relative pb-[17px] font-heading text-sm transition-colors ${
isActive
? 'font-semibold text-foreground'
: 'font-medium text-text-tertiary hover:text-foreground'
}`}
>
{item.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-copper" />
)}
</Link>
);
})}
</nav>
</div>
{/* Right: user info + mobile hamburger */}
<div className="flex items-center gap-4">
<div className="hidden items-center gap-3 md:flex">
<span className="text-sm text-text-secondary">
{user?.full_name ?? user?.email}
</span>
<button
onClick={logout}
className="text-sm text-text-tertiary hover:text-foreground"
>
Sign out
</button>
</div>
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-md p-1.5 text-text-tertiary hover:bg-mist md:hidden"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{mobileOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
{/* Mobile slide-down nav */}
{mobileOpen && (
<nav className="border-t border-border-subtle bg-card px-4 py-3 md:hidden">
{NAV_ITEMS.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileOpen(false)}
className={`block rounded-md px-3 py-2 text-sm font-medium ${
isActive
? 'bg-mist text-foreground'
: 'text-text-tertiary hover:bg-mist hover:text-foreground'
}`}
>
{item.label}
</Link>
);
})}
<div className="mt-3 border-t border-border-subtle pt-3">
<p className="px-3 text-sm text-text-secondary">
{user?.full_name ?? user?.email}
</p>
<button
onClick={logout}
className="mt-1 w-full rounded-md px-3 py-2 text-left text-sm text-text-tertiary hover:bg-mist hover:text-foreground"
>
Sign out
</button>
</div>
</nav>
)}
</header>
{/* Main content */}
<main className="w-full px-6 py-10 md:px-12">
<div className="mx-auto max-w-7xl">
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,477 @@
import { useMemo, useState } from 'react';
import type {
BannerConfig,
ComplianceFramework,
ComplianceIssue,
ComplianceSeverity,
ComplianceStatus,
FrameworkResult,
SiteConfig,
} from '../types/api';
import { Badge } from './ui/badge';
import { Card } from './ui/card';
import { EmptyState } from './ui/empty-state';
// ── Types ───────────────────────────────────────────────────────────
interface Props {
siteId: string;
config: SiteConfig | null;
}
interface ComplianceRule {
ruleId: string;
description: string;
check: (ctx: SiteContext) => ComplianceIssue[];
}
interface SiteContext {
blockingMode: string;
regionalModes: Record<string, string> | null;
tcfEnabled: boolean;
gcmEnabled: boolean;
consentExpiryDays: number;
privacyPolicyUrl: string | null;
bannerConfig: BannerConfig | null;
hasRejectButton: boolean;
hasGranularChoices: boolean;
hasCookieWall: boolean;
preTicked: boolean;
}
// ── Rule helpers ────────────────────────────────────────────────────
function issue(
ruleId: string,
severity: ComplianceSeverity,
message: string,
recommendation: string,
): ComplianceIssue {
return { rule_id: ruleId, severity, message, recommendation };
}
// ── GDPR rules ──────────────────────────────────────────────────────
const GDPR_RULES: ComplianceRule[] = [
{
ruleId: 'gdpr_opt_in',
description: 'Opt-in consent required',
check: (ctx) =>
ctx.blockingMode !== 'opt_in'
? [issue('gdpr_opt_in', 'critical', 'GDPR requires opt-in consent before setting non-essential cookies.', "Set blocking mode to 'opt_in'.")]
: [],
},
{
ruleId: 'gdpr_reject_button',
description: 'Reject as prominent as accept',
check: (ctx) =>
!ctx.hasRejectButton
? [issue('gdpr_reject_button', 'critical', 'The reject option must be as prominent as the accept option.', "Add a clearly visible 'Reject all' button to the first layer.")]
: [],
},
{
ruleId: 'gdpr_granular',
description: 'Granular category consent',
check: (ctx) =>
!ctx.hasGranularChoices
? [issue('gdpr_granular', 'critical', 'Users must be able to consent to individual cookie categories.', 'Provide granular category toggles in the consent banner.')]
: [],
},
{
ruleId: 'gdpr_cookie_wall',
description: 'No cookie walls',
check: (ctx) =>
ctx.hasCookieWall
? [issue('gdpr_cookie_wall', 'critical', 'Cookie walls (blocking access unless consent is given) are not permitted.', 'Remove the cookie wall and allow access without consent.')]
: [],
},
{
ruleId: 'gdpr_pre_ticked',
description: 'No pre-ticked boxes',
check: (ctx) =>
ctx.preTicked
? [issue('gdpr_pre_ticked', 'critical', 'Pre-ticked consent boxes do not constitute valid consent.', 'Ensure all non-essential category checkboxes default to unchecked.')]
: [],
},
{
ruleId: 'gdpr_privacy_policy',
description: 'Privacy policy link',
check: (ctx) =>
!ctx.privacyPolicyUrl
? [issue('gdpr_privacy_policy', 'warning', 'A link to the privacy policy should be accessible from the banner.', 'Configure a privacy policy URL in the site settings.')]
: [],
},
];
// ── CNIL rules (GDPR + French-specific) ─────────────────────────────
const CNIL_EXTRA_RULES: ComplianceRule[] = [
{
ruleId: 'cnil_reconsent',
description: 'Re-consent every 6 months',
check: (ctx) =>
ctx.consentExpiryDays > 182
? [issue('cnil_reconsent', 'critical', 'CNIL requires re-consent at least every 6 months.', 'Set consent expiry to 182 days or fewer.')]
: [],
},
{
ruleId: 'cnil_cookie_lifetime',
description: '13-month cookie lifetime',
check: (ctx) =>
ctx.consentExpiryDays > 395
? [issue('cnil_cookie_lifetime', 'critical', 'CNIL limits consent cookie lifetime to 13 months.', 'Set consent expiry to 395 days or fewer.')]
: [],
},
{
ruleId: 'cnil_reject_first_layer',
description: 'Reject on first layer',
check: (ctx) =>
!ctx.hasRejectButton
? [issue('cnil_reject_first_layer', 'critical', "CNIL requires a 'Reject all' button on the first layer of the banner.", "Ensure the 'Reject all' button is visible on the first banner view.")]
: [],
},
];
const CNIL_RULES: ComplianceRule[] = [...GDPR_RULES, ...CNIL_EXTRA_RULES];
// ── CCPA/CPRA rules ─────────────────────────────────────────────────
const CCPA_RULES: ComplianceRule[] = [
{
ruleId: 'ccpa_opt_out',
description: 'Opt-out mechanism',
check: (ctx) =>
ctx.blockingMode === 'informational'
? [issue('ccpa_opt_out', 'critical', 'CCPA requires at minimum an opt-out mechanism for data sale.', "Set blocking mode to 'opt_out' or 'opt_in'.")]
: [],
},
{
ruleId: 'ccpa_do_not_sell',
description: 'Do Not Sell link',
check: () => {
// Banner config doesn't have a DNS toggle yet — always flag as advisory
return [issue('ccpa_do_not_sell', 'warning', "CCPA requires a 'Do Not Sell My Personal Information' link on your site.", 'Add a Do Not Sell link to your website footer or privacy centre.')];
},
},
{
ruleId: 'ccpa_privacy_policy',
description: 'Privacy policy required',
check: (ctx) =>
!ctx.privacyPolicyUrl
? [issue('ccpa_privacy_policy', 'warning', 'A privacy policy is required under CCPA.', 'Configure a privacy policy URL in the site settings.')]
: [],
},
];
// ── ePrivacy rules ──────────────────────────────────────────────────
const EPRIVACY_RULES: ComplianceRule[] = [
{
ruleId: 'eprivacy_consent',
description: 'Consent for non-essential',
check: (ctx) =>
ctx.blockingMode === 'informational'
? [issue('eprivacy_consent', 'critical', 'ePrivacy Directive requires consent for non-essential cookies.', "Set blocking mode to 'opt_in' or 'opt_out'.")]
: [],
},
{
ruleId: 'eprivacy_necessary_exempt',
description: 'Necessary cookies exempt',
check: () => [],
},
];
// ── LGPD rules (Brazil) ─────────────────────────────────────────────
const LGPD_RULES: ComplianceRule[] = [
{
ruleId: 'lgpd_consent_basis',
description: 'Legal basis for processing',
check: (ctx) =>
ctx.blockingMode === 'informational'
? [issue('lgpd_consent_basis', 'critical', 'LGPD requires a legal basis (consent or legitimate interest) for data processing.', "Set blocking mode to 'opt_in' or 'opt_out'.")]
: [],
},
{
ruleId: 'lgpd_data_controller',
description: 'Identify data controller',
check: (ctx) =>
!ctx.privacyPolicyUrl
? [issue('lgpd_data_controller', 'warning', 'LGPD requires identification of the data controller.', 'Link to a privacy policy that identifies the data controller.')]
: [],
},
{
ruleId: 'lgpd_granular',
description: 'Granular consent choices',
check: (ctx) =>
!ctx.hasGranularChoices
? [issue('lgpd_granular', 'warning', 'LGPD recommends granular consent choices.', 'Provide individual category toggles in the consent banner.')]
: [],
},
];
// ── Framework registry ──────────────────────────────────────────────
const FRAMEWORK_RULES: Record<ComplianceFramework, ComplianceRule[]> = {
gdpr: GDPR_RULES,
cnil: CNIL_RULES,
ccpa: CCPA_RULES,
eprivacy: EPRIVACY_RULES,
lgpd: LGPD_RULES,
};
const FRAMEWORKS: { id: ComplianceFramework; label: string }[] = [
{ id: 'gdpr', label: 'GDPR' },
{ id: 'cnil', label: 'CNIL' },
{ id: 'ccpa', label: 'CCPA/CPRA' },
{ id: 'eprivacy', label: 'ePrivacy' },
{ id: 'lgpd', label: 'LGPD' },
];
// ── Compliance engine ───────────────────────────────────────────────
function buildContext(config: SiteConfig | null): SiteContext {
const bc = config?.banner_config ?? null;
return {
blockingMode: config?.blocking_mode ?? 'opt_in',
regionalModes: config?.regional_modes ?? null,
tcfEnabled: config?.tcf_enabled ?? false,
gcmEnabled: config?.gcm_enabled ?? true,
consentExpiryDays: config?.consent_expiry_days ?? 365,
privacyPolicyUrl: config?.privacy_policy_url ?? null,
bannerConfig: bc,
hasRejectButton: bc?.showRejectAll !== false,
hasGranularChoices: bc?.showManagePreferences !== false,
hasCookieWall: false, // Not a config option — always false
preTicked: false, // Banner never pre-ticks — always false
};
}
function runFrameworkCheck(framework: ComplianceFramework, ctx: SiteContext): FrameworkResult {
const rules = FRAMEWORK_RULES[framework];
const allIssues: ComplianceIssue[] = [];
let rulesPassed = 0;
for (const rule of rules) {
const issues = rule.check(ctx);
if (issues.length > 0) {
allIssues.push(...issues);
} else {
rulesPassed++;
}
}
const rulesChecked = rules.length;
const score = calculateScore(allIssues);
const hasCritical = allIssues.some((i) => i.severity === 'critical');
const status: ComplianceStatus = hasCritical ? 'non_compliant' : score >= 100 ? 'compliant' : 'partial';
return { framework, score, status, issues: allIssues, rules_checked: rulesChecked, rules_passed: rulesPassed };
}
function calculateScore(issues: ComplianceIssue[]): number {
let deductions = 0;
for (const i of issues) {
if (i.severity === 'critical') deductions += 20;
else if (i.severity === 'warning') deductions += 5;
}
return Math.max(0, 100 - deductions);
}
// ── UI Components ───────────────────────────────────────────────────
function ComplianceStatusBadge({ status }: { status: ComplianceStatus }) {
const variantMap: Record<ComplianceStatus, 'success' | 'warning' | 'error'> = {
compliant: 'success',
partial: 'warning',
non_compliant: 'error',
};
const labels: Record<ComplianceStatus, string> = {
compliant: 'Compliant',
partial: 'Partial',
non_compliant: 'Non-compliant',
};
return (
<Badge variant={variantMap[status]} className="text-xs font-semibold">
{labels[status]}
</Badge>
);
}
function SeverityIcon({ severity }: { severity: ComplianceSeverity }) {
const icons: Record<ComplianceSeverity, { symbol: string; colour: string }> = {
critical: { symbol: '!', colour: 'bg-status-error-fg text-white' },
warning: { symbol: '!', colour: 'bg-status-warning-fg text-white' },
info: { symbol: 'i', colour: 'bg-status-info-bg text-status-info-fg' },
};
const { symbol, colour } = icons[severity];
return (
<span className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold ${colour}`}>
{symbol}
</span>
);
}
function ScoreRing({ score }: { score: number }) {
const colour = score >= 80 ? 'text-status-success-fg' : score >= 50 ? 'text-status-warning-fg' : 'text-status-error-fg';
return (
<div className={`text-3xl font-bold ${colour}`}>
{score}
<span className="text-base font-normal text-text-tertiary">/100</span>
</div>
);
}
function IssueRow({ issueData }: { issueData: ComplianceIssue }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-b border-border last:border-0">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-mist"
>
<SeverityIcon severity={issueData.severity} />
<span className="flex-1 text-sm text-foreground">{issueData.message}</span>
<span className="text-xs text-text-tertiary">{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="bg-background px-4 pb-3 pl-12">
<p className="text-sm text-text-secondary">{issueData.recommendation}</p>
<p className="mt-1 font-mono text-xs text-text-tertiary">{issueData.rule_id}</p>
</div>
)}
</div>
);
}
function FrameworkCard({ result }: { result: FrameworkResult }) {
const [expanded, setExpanded] = useState(result.issues.length > 0);
const label = FRAMEWORKS.find((f) => f.id === result.framework)?.label ?? result.framework;
return (
<Card className="overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-4 py-4 text-left hover:bg-mist sm:px-5"
>
<div className="flex items-center gap-3 sm:gap-4">
<ScoreRing score={result.score} />
<div>
<h3 className="font-heading text-sm font-semibold text-foreground sm:text-base">{label}</h3>
<p className="text-xs text-text-secondary">
{result.rules_passed}/{result.rules_checked} rules passed
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<ComplianceStatusBadge status={result.status} />
{result.issues.length > 0 && (
<span className="hidden text-xs text-text-tertiary sm:inline">
{result.issues.length} issue{result.issues.length !== 1 ? 's' : ''} {expanded ? '▲' : '▼'}
</span>
)}
</div>
</button>
{expanded && result.issues.length > 0 && (
<div className="border-t border-border">
{result.issues.map((issueData) => (
<IssueRow key={issueData.rule_id} issueData={issueData} />
))}
</div>
)}
</Card>
);
}
// ── Main component ──────────────────────────────────────────────────
export default function SiteComplianceTab({ siteId: _siteId, config }: Props) {
const [selectedFrameworks, setSelectedFrameworks] = useState<Set<ComplianceFramework>>(
new Set(FRAMEWORKS.map((f) => f.id)),
);
// Run compliance checks purely on the frontend
const { results, overallScore } = useMemo(() => {
const ctx = buildContext(config);
const frameworkResults = [...selectedFrameworks].map((fw) => runFrameworkCheck(fw, ctx));
const overall = frameworkResults.length > 0
? Math.round(frameworkResults.reduce((sum, r) => sum + r.score, 0) / frameworkResults.length)
: 100;
return { results: frameworkResults, overallScore: overall };
}, [config, selectedFrameworks]);
const toggleFramework = (id: ComplianceFramework) => {
setSelectedFrameworks((prev) => {
const next = new Set(prev);
if (next.has(id)) {
if (next.size > 1) next.delete(id); // Keep at least one selected
} else {
next.add(id);
}
return next;
});
};
if (!config) {
return (
<EmptyState message="No site configuration found. Configure your site first." />
);
}
return (
<div>
{/* Header */}
<div className="mb-4 sm:mb-6">
<h2 className="font-heading text-lg font-semibold text-foreground">Compliance Checker</h2>
<p className="mt-1 text-sm text-text-secondary">
Your site configuration is checked against regulatory frameworks in real time.
</p>
</div>
{/* Framework selector */}
<div className="mb-4 flex flex-wrap gap-2 sm:mb-6">
{FRAMEWORKS.map((fw) => (
<button
key={fw.id}
onClick={() => toggleFramework(fw.id)}
className={`rounded-full border px-3 py-1 text-sm font-medium transition ${
selectedFrameworks.has(fw.id)
? 'border-copper bg-copper/10 text-copper'
: 'border-border bg-card text-text-secondary hover:border-border'
}`}
>
{fw.label}
</button>
))}
</div>
{/* Overall score */}
<Card className="mb-4 p-4 sm:mb-6 sm:p-5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-heading text-sm font-medium text-text-secondary">Overall Compliance Score</h3>
<div className="mt-1">
<ScoreRing score={overallScore} />
</div>
</div>
<div className="text-right text-sm text-text-secondary">
{results.length} framework{results.length !== 1 ? 's' : ''} checked
</div>
</div>
</Card>
{/* Per-framework results */}
<div className="space-y-3 sm:space-y-4">
{results.map((result) => (
<FrameworkCard key={result.framework} result={result} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,467 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { getConfigInheritance, updateSiteConfig } from '../api/sites';
import { trackConfigChange } from '../services/analytics';
import type { ConfigInheritanceResponse, ConfigSource, SiteConfig } from '../types/api';
import { Alert } from './ui/alert';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { FormField } from './ui/form-field';
import { Input } from './ui/input';
import { Select } from './ui/select';
interface Props {
siteId: string;
config: SiteConfig | null;
}
const GPP_SECTIONS = [
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
];
const GPC_JURISDICTIONS = [
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
{ value: 'US-CO', label: 'Colorado (CPA)' },
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
{ value: 'US-TX', label: 'Texas (TDPSA)' },
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
];
const SOURCE_LABELS: Record<ConfigSource, string> = {
system: 'System default',
org: 'Organisation default',
group: 'Group default',
site: 'Site override',
};
const SOURCE_COLOURS: Record<ConfigSource, string> = {
system: 'bg-gray-100 text-gray-600',
org: 'bg-blue-50 text-blue-700',
group: 'bg-purple-50 text-purple-700',
site: 'bg-green-50 text-green-700',
};
function SourceBadge({ source, field }: { source: ConfigSource; field: string }) {
if (source === 'site') return null;
return (
<span
className={`ml-2 inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${SOURCE_COLOURS[source]}`}
title={`The value for "${field}" is inherited from ${SOURCE_LABELS[source].toLowerCase()}`}
>
{SOURCE_LABELS[source]}
</span>
);
}
/**
* Button to reset a field to its inherited default.
* Only shown when the field is overridden at site level.
*/
function ResetButton({
field,
inheritance,
onReset,
}: {
field: string;
inheritance: ConfigInheritanceResponse | undefined;
onReset: () => void;
}) {
const source = inheritance?.fields[field]?.source;
if (source !== 'site') return null;
const parentSource = getParentSource(field, inheritance);
const label = parentSource
? `Reset to ${SOURCE_LABELS[parentSource].toLowerCase()}`
: 'Reset to default';
return (
<button
type="button"
onClick={onReset}
className="ml-2 text-[10px] font-medium text-primary hover:text-primary/80 hover:underline"
title={label}
>
{label}
</button>
);
}
/** Determine which parent level would provide the value if site override is removed. */
function getParentSource(
field: string,
inheritance: ConfigInheritanceResponse | undefined,
): ConfigSource | null {
if (!inheritance) return null;
const info = inheritance.fields[field];
if (!info) return null;
if (info.group_value != null) return 'group';
if (info.org_value != null) return 'org';
return 'system';
}
export default function SiteConfigTab({ siteId, config }: Props) {
const queryClient = useQueryClient();
const [blockingMode, setBlockingMode] = useState<string>(config?.blocking_mode ?? 'opt_in');
const [tcfEnabled, setTcfEnabled] = useState(config?.tcf_enabled ?? false);
const [gcmEnabled, setGcmEnabled] = useState(config?.gcm_enabled ?? true);
const [shopifyEnabled, setShopifyEnabled] = useState(config?.shopify_privacy_enabled ?? false);
const [consentExpiry, setConsentExpiry] = useState(config?.consent_expiry_days ?? 365);
const [privacyUrl, setPrivacyUrl] = useState(config?.privacy_policy_url ?? '');
const [termsUrl, setTermsUrl] = useState(config?.terms_url ?? '');
// GPP state
const [gppEnabled, setGppEnabled] = useState(config?.gpp_enabled ?? true);
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>(
config?.gpp_supported_apis ?? ['usnat'],
);
// GPC state
const [gpcEnabled, setGpcEnabled] = useState(config?.gpc_enabled ?? true);
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>(
config?.gpc_jurisdictions ?? ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
);
const [gpcGlobalHonour, setGpcGlobalHonour] = useState(config?.gpc_global_honour ?? false);
// Track which fields should be sent as null (reset to default)
const [resetFields, setResetFields] = useState<Set<string>>(new Set());
const [saved, setSaved] = useState(false);
const { data: inheritance } = useQuery({
queryKey: ['sites', siteId, 'inheritance'],
queryFn: () => getConfigInheritance(siteId),
enabled: !!siteId,
});
const mutation = useMutation({
mutationFn: (body: Record<string, unknown>) => updateSiteConfig(siteId, body as Partial<SiteConfig>),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'inheritance'] });
trackConfigChange('site_config', { site_id: siteId });
setResetFields(new Set());
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const markReset = (field: string) => {
setResetFields((prev) => new Set([...prev, field]));
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const body: Record<string, unknown> = {
blocking_mode: blockingMode,
tcf_enabled: tcfEnabled,
gcm_enabled: gcmEnabled,
shopify_privacy_enabled: shopifyEnabled,
consent_expiry_days: consentExpiry,
privacy_policy_url: privacyUrl || null,
terms_url: termsUrl || null,
gpp_enabled: gppEnabled,
gpp_supported_apis: gppEnabled ? gppSupportedApis : null,
gpc_enabled: gpcEnabled,
gpc_jurisdictions: gpcEnabled ? gpcJurisdictions : null,
gpc_global_honour: gpcGlobalHonour,
};
// Override any fields marked for reset with null
for (const field of resetFields) {
body[field] = null;
}
mutation.mutate(body);
};
const toggleGppSection = (api: string) => {
setGppSupportedApis((prev) =>
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
);
};
const toggleGpcJurisdiction = (code: string) => {
setGpcJurisdictions((prev) =>
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
);
};
const getSource = (field: string): ConfigSource => {
return inheritance?.fields[field]?.source ?? 'site';
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Inheritance info banner */}
{inheritance && (
<div className="rounded-xl border border-dashed border-border bg-surface p-4">
<p className="text-xs text-text-secondary">
<strong>Configuration cascade:</strong> System defaults
{' \u2192 '}Organisation defaults
{inheritance.site_group_id && <>{' \u2192 '}Group defaults</>}
{' \u2192 '}<span className="font-semibold">Site config</span>
{' \u2192 '}Regional overrides.
Fields with a coloured badge are inherited from a higher level.
Click &ldquo;Reset&rdquo; to remove a site-level override and inherit the parent value.
</p>
</div>
)}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Consent settings</h3>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<div className="flex items-center">
<FormField label="Blocking mode">
<Select
value={blockingMode}
onChange={(e) => setBlockingMode(e.target.value)}
>
<option value="opt_in">Opt-in (GDPR)</option>
<option value="opt_out">Opt-out (CCPA)</option>
<option value="informational">Informational only</option>
</Select>
</FormField>
<SourceBadge source={getSource('blocking_mode')} field="blocking mode" />
<ResetButton field="blocking_mode" inheritance={inheritance} onReset={() => markReset('blocking_mode')} />
</div>
</div>
<div>
<div className="flex items-center">
<FormField label="Consent expiry (days)">
<Input
type="number"
min={1}
max={730}
value={consentExpiry}
onChange={(e) => setConsentExpiry(Number(e.target.value))}
/>
</FormField>
<SourceBadge source={getSource('consent_expiry_days')} field="consent expiry" />
<ResetButton field="consent_expiry_days" inheritance={inheritance} onReset={() => markReset('consent_expiry_days')} />
</div>
</div>
<div>
<div className="flex items-center">
<FormField label="Privacy policy URL">
<Input
type="url"
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder="https://example.com/privacy"
/>
</FormField>
<SourceBadge source={getSource('privacy_policy_url')} field="privacy policy URL" />
<ResetButton field="privacy_policy_url" inheritance={inheritance} onReset={() => { setPrivacyUrl(''); markReset('privacy_policy_url'); }} />
</div>
</div>
<div>
<div className="flex items-center">
<FormField label="Terms & conditions URL">
<Input
type="url"
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder="https://example.com/terms"
/>
</FormField>
<SourceBadge source={getSource('terms_url')} field="terms URL" />
<ResetButton field="terms_url" inheritance={inheritance} onReset={() => { setTermsUrl(''); markReset('terms_url'); }} />
</div>
<p className="mt-1 text-xs text-text-secondary">
Use <code className="rounded bg-surface px-1">{'{{privacy_policy}}'}</code> and{' '}
<code className="rounded bg-surface px-1">{'{{terms}}'}</code> in your banner
description with markdown links, e.g.{' '}
<code className="rounded bg-surface px-1">{'[Privacy Policy]({{privacy_policy}})'}</code>
</p>
</div>
</div>
</Card>
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Standards &amp; integrations</h3>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={tcfEnabled}
onChange={(e) => setTcfEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div className="flex items-center">
<span className="text-sm font-medium text-text-secondary">IAB TCF v2.2</span>
<SourceBadge source={getSource('tcf_enabled')} field="TCF" />
<ResetButton field="tcf_enabled" inheritance={inheritance} onReset={() => markReset('tcf_enabled')} />
</div>
</label>
<p className="ml-7 text-xs text-text-secondary">Enable Transparency and Consent Framework</p>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={gcmEnabled}
onChange={(e) => setGcmEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div className="flex items-center">
<span className="text-sm font-medium text-text-secondary">Google Consent Mode v2</span>
<SourceBadge source={getSource('gcm_enabled')} field="GCM" />
<ResetButton field="gcm_enabled" inheritance={inheritance} onReset={() => markReset('gcm_enabled')} />
</div>
</label>
<p className="ml-7 text-xs text-text-secondary">Automatically set gtag consent signals</p>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={shopifyEnabled}
onChange={(e) => setShopifyEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div className="flex items-center">
<span className="text-sm font-medium text-text-secondary">Shopify Customer Privacy API</span>
<SourceBadge source={getSource('shopify_privacy_enabled')} field="Shopify Privacy" />
<ResetButton field="shopify_privacy_enabled" inheritance={inheritance} onReset={() => markReset('shopify_privacy_enabled')} />
</div>
</label>
<p className="ml-7 text-xs text-text-secondary">
Bridge consent decisions to Shopify&apos;s <code>setTrackingConsent()</code> API.
Enable this for Shopify-hosted stores.
</p>
</div>
</Card>
{/* Privacy Signals — GPP */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">IAB Global Privacy Platform (GPP)</h3>
<p className="mb-4 text-xs text-text-secondary">
GPP provides a standardised consent string format for US state privacy laws.
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP strings
for the selected sections.
</p>
<label className="mb-4 flex items-center gap-3">
<input
type="checkbox"
checked={gppEnabled}
onChange={(e) => setGppEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div className="flex items-center">
<span className="text-sm font-medium text-text-secondary">Enable GPP</span>
<ResetButton field="gpp_enabled" inheritance={inheritance} onReset={() => markReset('gpp_enabled')} />
</div>
</label>
{gppEnabled && (
<div className="ml-7 space-y-2">
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
{GPP_SECTIONS.map((section) => (
<label key={section.value} className="flex items-center gap-2">
<input
type="checkbox"
checked={gppSupportedApis.includes(section.value)}
onChange={() => toggleGppSection(section.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{section.label}</span>
</label>
))}
</div>
)}
</Card>
{/* Privacy Signals — GPC */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Global Privacy Control (GPC)</h3>
<p className="mb-4 text-xs text-text-secondary">
GPC is a browser signal indicating a user&apos;s intent to opt out of the sale or
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
legally require businesses to honour this signal.
</p>
<label className="mb-4 flex items-center gap-3">
<input
type="checkbox"
checked={gpcEnabled}
onChange={(e) => setGpcEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div className="flex items-center">
<span className="text-sm font-medium text-text-secondary">Detect GPC signal</span>
<ResetButton field="gpc_enabled" inheritance={inheritance} onReset={() => markReset('gpc_enabled')} />
</div>
</label>
{gpcEnabled && (
<div className="ml-7 space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={gpcGlobalHonour}
onChange={(e) => setGpcGlobalHonour(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary"
/>
<div>
<span className="text-sm font-medium text-text-secondary">Honour globally</span>
<p className="text-xs text-text-secondary">
Apply GPC opt-out for all visitors regardless of jurisdiction
</p>
</div>
</label>
{!gpcGlobalHonour && (
<div>
<p className="mb-2 text-xs font-medium text-text-secondary">
Jurisdictions where GPC is legally required
</p>
{GPC_JURISDICTIONS.map((j) => (
<label key={j.value} className="flex items-center gap-2 py-0.5">
<input
type="checkbox"
checked={gpcJurisdictions.includes(j.value)}
onChange={() => toggleGpcJurisdiction(j.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{j.label}</span>
</label>
))}
</div>
)}
</div>
)}
</Card>
<div className="flex items-center gap-3">
<Button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Save configuration'}
</Button>
{resetFields.size > 0 && (
<span className="text-xs text-text-secondary">
{resetFields.size} field{resetFields.size > 1 ? 's' : ''} will be reset to default
</span>
)}
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
{mutation.isError && (
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,150 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCookieSummary, listCategories, listCookies, updateCookie } from '../api/cookies';
import type { Cookie, CookieCategory } from '../types/api';
import { Badge } from './ui/badge';
import { EmptyState } from './ui/empty-state';
import { LoadingState } from './ui/loading-state';
import { MetricCard } from './ui/metric-card';
import { Select } from './ui/select';
interface Props {
siteId: string;
}
export default function SiteCookiesTab({ siteId }: Props) {
const queryClient = useQueryClient();
const { data: cookies, isLoading } = useQuery({
queryKey: ['cookies', siteId],
queryFn: () => listCookies(siteId),
});
const { data: categories } = useQuery({
queryKey: ['cookie-categories'],
queryFn: listCategories,
});
const { data: summary } = useQuery({
queryKey: ['cookies', siteId, 'summary'],
queryFn: () => getCookieSummary(siteId),
});
const updateMutation = useMutation({
mutationFn: ({ cookieId, body }: { cookieId: string; body: Partial<Cookie> }) =>
updateCookie(siteId, cookieId, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cookies', siteId] });
queryClient.invalidateQueries({ queryKey: ['cookies', siteId, 'summary'] });
},
});
if (isLoading) {
return <LoadingState message="Loading cookies..." />;
}
return (
<div className="space-y-6">
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<MetricCard label="Total" value={summary.total} />
<MetricCard label="Pending review" value={summary.by_status?.pending ?? 0} />
<MetricCard label="Approved" value={summary.by_status?.approved ?? 0} />
<MetricCard label="Uncategorised" value={summary.uncategorised} />
</div>
)}
{/* Cookies table */}
{cookies && cookies.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-background text-left text-xs font-medium uppercase tracking-wide text-text-secondary">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Domain</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{cookies.map((cookie: Cookie) => (
<tr key={cookie.id} className="transition hover:bg-mist">
<td className="px-4 py-3 text-sm font-mono text-foreground">{cookie.name}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{cookie.domain}</td>
<td className="px-4 py-3">
<Select
value={cookie.category_id ?? ''}
onChange={(e) =>
updateMutation.mutate({
cookieId: cookie.id,
body: { category_id: e.target.value || null },
})
}
className="h-auto w-auto px-2 py-1 text-xs"
>
<option value="">Uncategorised</option>
{(categories ?? []).map((cat: CookieCategory) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</Select>
</td>
<td className="px-4 py-3">
<Badge
variant={
cookie.review_status === 'approved'
? 'success'
: cookie.review_status === 'rejected'
? 'error'
: cookie.review_status === 'pending'
? 'warning'
: 'neutral'
}
>
{cookie.review_status}
</Badge>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{cookie.review_status !== 'approved' && (
<button
onClick={() =>
updateMutation.mutate({
cookieId: cookie.id,
body: { review_status: 'approved' },
})
}
className="rounded bg-status-success-bg px-2 py-1 text-xs font-medium text-status-success-fg hover:opacity-80"
>
Approve
</button>
)}
{cookie.review_status !== 'rejected' && (
<button
onClick={() =>
updateMutation.mutate({
cookieId: cookie.id,
body: { review_status: 'rejected' },
})
}
className="rounded bg-status-error-bg px-2 py-1 text-xs font-medium text-status-error-fg hover:opacity-80"
>
Reject
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState message="No cookies discovered yet. Run a scan or wait for client-side reporting." />
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Card } from './ui/card';
import { MetricCard } from './ui/metric-card';
import type { Site, SiteConfig } from '../types/api';
interface Props {
site: Site;
config: SiteConfig | null;
}
export default function SiteOverviewTab({ site, config }: Props) {
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}" async></script>`;
return (
<div className="space-y-6">
{/* Status cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<MetricCard
label="Status"
value={site.is_active ? 'Active' : 'Inactive'}
className={site.is_active ? 'text-status-success-fg' : ''}
/>
<MetricCard
label="Blocking mode"
value={config?.blocking_mode?.replace('_', ' ') ?? 'Not configured'}
className="capitalize"
/>
<MetricCard
label="Consent expiry"
value={`${config?.consent_expiry_days ?? 365} days`}
/>
</div>
{/* Integration snippet */}
<Card className="p-6">
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">Integration snippet</h3>
<p className="mb-3 text-sm text-text-secondary">
Add this script tag to the {'<head>'} of your website, before any other scripts.
</p>
<div className="relative">
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
{scriptTag}
</pre>
<button
onClick={() => navigator.clipboard.writeText(scriptTag)}
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
>
Copy
</button>
</div>
</Card>
{/* Features */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Features</h3>
<div className="grid grid-cols-2 gap-3">
<FeatureItem label="TCF v2.2" enabled={config?.tcf_enabled ?? false} />
<FeatureItem label="Google Consent Mode" enabled={config?.gcm_enabled ?? false} />
<FeatureItem label="Auto-blocking" enabled={config?.blocking_mode !== 'informational'} />
<FeatureItem label="Custom banner" enabled={!!config?.banner_config} />
</div>
</Card>
</div>
);
}
function FeatureItem({ label, enabled }: { label: string; enabled: boolean }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
<span
className={`h-2 w-2 rounded-full ${enabled ? 'bg-status-success-fg' : 'bg-text-tertiary'}`}
/>
<span className="text-sm text-text-secondary">{label}</span>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { getScan, getScanDiff, listScans, triggerScan } from '../api/scanner';
import { trackFeatureUsage } from '../services/analytics';
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult } from '../types/api';
import { Alert } from './ui/alert';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { LoadingState } from './ui/loading-state';
interface Props {
siteId: string;
}
function statusVariant(status: string): 'warning' | 'info' | 'success' | 'error' | 'neutral' {
const map: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
pending: 'warning',
running: 'info',
completed: 'success',
failed: 'error',
};
return map[status] ?? 'neutral';
}
function diffVariant(status: string): 'success' | 'error' | 'warning' | 'neutral' {
const map: Record<string, 'success' | 'error' | 'warning'> = {
new: 'success',
removed: 'error',
changed: 'warning',
};
return map[status] ?? 'neutral';
}
function DiffSection({ title, items }: { title: string; items: CookieDiffItem[] }) {
if (items.length === 0) return null;
return (
<div className="mt-4">
<h4 className="text-sm font-medium text-text-secondary">{title} ({items.length})</h4>
<div className="mt-2 overflow-hidden rounded-md border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-background">
<tr>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Name</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Domain</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Type</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Status</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item, idx) => (
<tr key={`${item.name}-${item.domain}-${idx}`}>
<td className="px-3 py-2 font-mono text-xs">{item.name}</td>
<td className="px-3 py-2 text-text-secondary">{item.domain}</td>
<td className="px-3 py-2 text-text-secondary">{item.storage_type}</td>
<td className="px-3 py-2"><Badge variant={diffVariant(item.diff_status)}>{item.diff_status}</Badge></td>
<td className="px-3 py-2 text-text-secondary">{item.details ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function ScanDiffView({ scanId }: { scanId: string }) {
const { data: diff, isLoading } = useQuery<ScanDiff>({
queryKey: ['scans', scanId, 'diff'],
queryFn: () => getScanDiff(scanId),
});
if (isLoading) return <LoadingState message="Loading diff..." className="py-2" />;
if (!diff) return null;
const hasChanges = diff.total_new + diff.total_removed + diff.total_changed > 0;
return (
<div className="mt-3 rounded-md border border-border bg-background p-4">
<h3 className="font-heading text-sm font-semibold text-foreground">
Scan Diff
{diff.previous_scan_id ? '' : ' (first scan — no comparison available)'}
</h3>
{hasChanges ? (
<>
<DiffSection title="New Cookies" items={diff.new_cookies} />
<DiffSection title="Removed Cookies" items={diff.removed_cookies} />
<DiffSection title="Changed Cookies" items={diff.changed_cookies} />
</>
) : (
<p className="mt-2 text-sm text-text-secondary">No changes detected.</p>
)}
</div>
);
}
function InitiatorChain({ chain }: { chain: string[] }) {
if (chain.length === 0) return <span className="text-text-tertiary"></span>;
return (
<div className="flex flex-wrap items-center gap-1 text-xs">
{chain.map((url, idx) => {
// Show just the pathname for brevity
let label: string;
try {
const parsed = new URL(url);
label = parsed.pathname.length > 40
? '…' + parsed.pathname.slice(-38)
: parsed.pathname;
} catch {
label = url.length > 40 ? '…' + url.slice(-38) : url;
}
return (
<span key={idx} className="flex items-center gap-1">
{idx > 0 && <span className="text-text-tertiary"></span>}
<span
className="rounded bg-mist px-1.5 py-0.5 font-mono text-text-secondary"
title={url}
>
{label}
</span>
</span>
);
})}
</div>
);
}
function ScanResultsView({ scanId }: { scanId: string }) {
const { data: detail, isLoading } = useQuery<ScanJobDetail>({
queryKey: ['scans', scanId, 'detail'],
queryFn: () => getScan(scanId),
});
if (isLoading) return <LoadingState message="Loading results..." className="py-2" />;
if (!detail || detail.results.length === 0) {
return <p className="py-2 text-sm text-text-secondary">No results recorded.</p>;
}
// Only show results that have an initiator chain
const withChain = detail.results.filter(
(r: ScanResult) => r.initiator_chain && r.initiator_chain.length > 1,
);
if (withChain.length === 0) {
return <p className="py-2 text-sm text-text-secondary">No initiator chains detected in this scan.</p>;
}
return (
<div className="mt-4">
<h4 className="text-sm font-medium text-text-secondary">
Initiator Chains ({withChain.length} cookies)
</h4>
<div className="mt-2 overflow-hidden rounded-md border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-background">
<tr>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Cookie</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Domain</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Chain</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{withChain.map((r: ScanResult) => (
<tr key={r.id}>
<td className="px-3 py-2 font-mono text-xs">{r.cookie_name}</td>
<td className="px-3 py-2 text-text-secondary">{r.cookie_domain}</td>
<td className="px-3 py-2">
<InitiatorChain chain={r.initiator_chain!} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default function SiteScannerTab({ siteId }: Props) {
const queryClient = useQueryClient();
const [expandedScanId, setExpandedScanId] = useState<string | null>(null);
const { data: scans, isLoading } = useQuery<ScanJob[]>({
queryKey: ['scans', siteId],
queryFn: () => listScans(siteId),
});
const triggerMutation = useMutation({
mutationFn: () => triggerScan(siteId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scans', siteId] });
trackFeatureUsage('scan', 'trigger', { site_id: siteId });
},
});
if (isLoading) {
return <LoadingState message="Loading scans..." />;
}
return (
<div>
{/* Header with trigger button */}
<div className="mb-4 flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">Cookie Scans</h2>
<Button
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
>
{triggerMutation.isPending ? 'Triggering...' : 'Trigger Scan'}
</Button>
</div>
{triggerMutation.isError && (
<Alert variant="error" className="mb-4">
Failed to trigger scan. A scan may already be in progress.
</Alert>
)}
{/* Scan history */}
{!scans || scans.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
No scans yet. Trigger a scan to discover cookies on your site.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-background">
<tr>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Status</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Trigger</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Pages</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Cookies Found</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Started</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Completed</th>
<th className="px-4 py-3 text-left font-medium text-text-secondary">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{scans.map((scan) => (
<Fragment key={scan.id}>
<tr className="hover:bg-mist">
<td className="px-4 py-3"><Badge variant={statusVariant(scan.status)}>{scan.status}</Badge></td>
<td className="px-4 py-3 text-text-secondary">{scan.trigger}</td>
<td className="px-4 py-3 text-text-secondary">
{scan.pages_scanned}{scan.pages_total ? ` / ${scan.pages_total}` : ''}
</td>
<td className="px-4 py-3 text-text-secondary">{scan.cookies_found}</td>
<td className="px-4 py-3 text-text-secondary">
{scan.started_at ? new Date(scan.started_at).toLocaleString() : '—'}
</td>
<td className="px-4 py-3 text-text-secondary">
{scan.completed_at ? new Date(scan.completed_at).toLocaleString() : '—'}
</td>
<td className="px-4 py-3">
{scan.status === 'completed' && (
<button
onClick={() => setExpandedScanId(expandedScanId === scan.id ? null : scan.id)}
className="text-copper hover:text-copper/80 text-xs font-medium"
>
{expandedScanId === scan.id ? 'Hide Diff' : 'View Diff'}
</button>
)}
{scan.status === 'failed' && scan.error_message && (
<span className="text-xs text-status-error-fg" title={scan.error_message}>
Error
</span>
)}
</td>
</tr>
{expandedScanId === scan.id && (
<tr key={`${scan.id}-diff`}>
<td colSpan={7} className="px-4 py-2">
<ScanDiffView scanId={scan.id} />
<ScanResultsView scanId={scan.id} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,397 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import {
createTranslation,
deleteTranslation,
listTranslations,
updateTranslation,
} from '../api/translations';
import type { Translation } from '../types/api';
import { Alert } from './ui/alert';
import { Button } from './ui/button';
import { Card, CardContent } from './ui/card';
import { EmptyState } from './ui/empty-state';
import { FormField } from './ui/form-field';
import { Input } from './ui/input';
import { LoadingState } from './ui/loading-state';
import { Modal } from './ui/modal';
import { Select } from './ui/select';
import { Textarea } from './ui/textarea';
/** The translation keys that the banner script expects. */
const TRANSLATION_KEYS = [
{ key: 'title', label: 'Banner title', placeholder: 'We use cookies' },
{
key: 'description',
label: 'Banner description',
placeholder: 'We use cookies and similar technologies...',
multiline: true,
},
{ key: 'acceptAll', label: 'Accept all button', placeholder: 'Accept all' },
{ key: 'rejectAll', label: 'Reject all button', placeholder: 'Reject all' },
{
key: 'managePreferences',
label: 'Manage preferences button',
placeholder: 'Manage preferences',
},
{ key: 'savePreferences', label: 'Save preferences button', placeholder: 'Save preferences' },
{ key: 'privacyPolicyLink', label: 'Privacy policy link text', placeholder: 'Privacy Policy' },
{ key: 'closeLabel', label: 'Close button label', placeholder: 'Close' },
{ key: 'categoryNecessary', label: 'Necessary category', placeholder: 'Necessary' },
{
key: 'categoryNecessaryDesc',
label: 'Necessary description',
placeholder: 'Essential for the website to function.',
},
{ key: 'categoryFunctional', label: 'Functional category', placeholder: 'Functional' },
{
key: 'categoryFunctionalDesc',
label: 'Functional description',
placeholder: 'Enable enhanced functionality.',
},
{ key: 'categoryAnalytics', label: 'Analytics category', placeholder: 'Analytics' },
{
key: 'categoryAnalyticsDesc',
label: 'Analytics description',
placeholder: 'Help us understand how visitors interact.',
},
{ key: 'categoryMarketing', label: 'Marketing category', placeholder: 'Marketing' },
{
key: 'categoryMarketingDesc',
label: 'Marketing description',
placeholder: 'Used to deliver personalised advertisements.',
},
{
key: 'categoryPersonalisation',
label: 'Personalisation category',
placeholder: 'Personalisation',
},
{
key: 'categoryPersonalisationDesc',
label: 'Personalisation description',
placeholder: 'Enable content personalisation.',
},
{
key: 'cookieCount',
label: 'Cookie count text',
placeholder: '{{count}} cookies used on this site',
},
];
const COMMON_LOCALES = [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'es', name: 'Spanish' },
{ code: 'it', name: 'Italian' },
{ code: 'nl', name: 'Dutch' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'pl', name: 'Polish' },
{ code: 'sv', name: 'Swedish' },
{ code: 'da', name: 'Danish' },
{ code: 'fi', name: 'Finnish' },
{ code: 'no', name: 'Norwegian' },
{ code: 'cs', name: 'Czech' },
{ code: 'ro', name: 'Romanian' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'bg', name: 'Bulgarian' },
{ code: 'hr', name: 'Croatian' },
{ code: 'sk', name: 'Slovak' },
{ code: 'sl', name: 'Slovenian' },
{ code: 'el', name: 'Greek' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ar', name: 'Arabic' },
];
interface Props {
siteId: string;
}
export default function SiteTranslationsTab({ siteId }: Props) {
const queryClient = useQueryClient();
const [selectedLocale, setSelectedLocale] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const { data: translations, isLoading } = useQuery({
queryKey: ['sites', siteId, 'translations'],
queryFn: () => listTranslations(siteId),
});
const deleteMutation = useMutation({
mutationFn: (locale: string) => deleteTranslation(siteId, locale),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
setSelectedLocale(null);
},
});
if (isLoading) {
return <LoadingState />;
}
const existing = translations ?? [];
const selected = existing.find((t) => t.locale === selectedLocale);
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-heading text-sm font-semibold text-foreground">Translations</h3>
<p className="mt-0.5 text-xs text-text-secondary">
Manage banner text for different languages. English is the default fallback.
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
Add language
</Button>
</div>
{existing.length === 0 ? (
<EmptyState message="No translations yet. The banner will use English defaults." />
) : (
<div className="flex flex-wrap gap-2">
{existing.map((t) => (
<button
key={t.locale}
onClick={() => setSelectedLocale(t.locale)}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition ${
selectedLocale === t.locale
? 'border-copper bg-copper/10 text-copper'
: 'border-border text-text-secondary hover:bg-mist'
}`}
>
{localeName(t.locale)}
<span className="ml-1.5 text-xs text-text-tertiary">{t.locale}</span>
</button>
))}
</div>
)}
</CardContent>
</Card>
{selected && (
<TranslationEditor
siteId={siteId}
translation={selected}
onDelete={() => {
if (confirm(`Delete ${localeName(selected.locale)} translation?`)) {
deleteMutation.mutate(selected.locale);
}
}}
/>
)}
<CreateTranslationModal
open={showCreate}
siteId={siteId}
existingLocales={existing.map((t) => t.locale)}
onClose={() => setShowCreate(false)}
onCreated={(locale) => {
setShowCreate(false);
setSelectedLocale(locale);
}}
/>
</div>
);
}
/* ── Translation editor ──────────────────────────────────────────────── */
function TranslationEditor({
siteId,
translation,
onDelete,
}: {
siteId: string;
translation: Translation;
onDelete: () => void;
}) {
const queryClient = useQueryClient();
const [strings, setStrings] = useState<Record<string, string>>(translation.strings);
const [saved, setSaved] = useState(false);
// Reset state when switching locales
const [currentLocale, setCurrentLocale] = useState(translation.locale);
if (translation.locale !== currentLocale) {
setStrings(translation.strings);
setCurrentLocale(translation.locale);
setSaved(false);
}
const mutation = useMutation({
mutationFn: (body: { strings: Record<string, string> }) =>
updateTranslation(siteId, translation.locale, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({ strings });
};
const filledCount = TRANSLATION_KEYS.filter((k) => strings[k.key]?.trim()).length;
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Card>
<CardContent className="p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-heading text-sm font-semibold text-foreground">
{localeName(translation.locale)}{' '}
<span className="font-normal text-text-tertiary">({translation.locale})</span>
</h3>
<p className="mt-0.5 text-xs text-text-secondary">
{filledCount}/{TRANSLATION_KEYS.length} strings translated. Empty strings fall back
to English.
</p>
</div>
<button
type="button"
onClick={onDelete}
className="text-xs text-status-error-fg hover:underline"
>
Delete language
</button>
</div>
<div className="space-y-4">
{TRANSLATION_KEYS.map(({ key, label, placeholder, multiline }) => (
<div key={key}>
<label className="mb-1 block text-xs font-medium text-text-secondary">
{label}
<span className="ml-1 font-mono text-text-tertiary">{key}</span>
</label>
{multiline ? (
<Textarea
value={strings[key] ?? ''}
onChange={(e) => setStrings({ ...strings, [key]: e.target.value })}
placeholder={placeholder}
rows={3}
/>
) : (
<Input
type="text"
value={strings[key] ?? ''}
onChange={(e) => setStrings({ ...strings, [key]: e.target.value })}
placeholder={placeholder}
/>
)}
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Save translation'}
</Button>
{saved && <span className="text-sm text-status-success-fg">Saved successfully</span>}
{mutation.isError && (
<span className="text-sm text-status-error-fg">Failed to save. Please try again.</span>
)}
</div>
</form>
);
}
/* ── Create translation modal ────────────────────────────────────────── */
function CreateTranslationModal({
open,
siteId,
existingLocales,
onClose,
onCreated,
}: {
open: boolean;
siteId: string;
existingLocales: string[];
onClose: () => void;
onCreated: (locale: string) => void;
}) {
const queryClient = useQueryClient();
const [locale, setLocale] = useState('');
const [error, setError] = useState('');
const availableLocales = COMMON_LOCALES.filter((l) => !existingLocales.includes(l.code));
const mutation = useMutation({
mutationFn: () => createTranslation(siteId, { locale, strings: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
onCreated(locale);
},
onError: () => {
setError('Failed to create translation. The locale may already exist.');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!locale) return;
setError('');
mutation.mutate();
};
return (
<Modal open={open} onClose={onClose} title="Add language">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="error">{error}</Alert>
)}
<FormField label="Language" htmlFor="locale">
<Select
id="locale"
required
value={locale}
onChange={(e) => setLocale(e.target.value)}
>
<option value="">Select a language...</option>
{availableLocales.map((l) => (
<option key={l.code} value={l.code}>
{l.name} ({l.code})
</option>
))}
</Select>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
disabled={mutation.isPending || !locale}
>
{mutation.isPending ? 'Creating...' : 'Add language'}
</Button>
</div>
</form>
</Modal>
);
}
/* ── Helpers ──────────────────────────────────────────────────────────── */
function localeName(code: string): string {
const match = COMMON_LOCALES.find((l) => l.code === code);
return match?.name ?? code.toUpperCase();
}

View File

@@ -0,0 +1,35 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils.ts";
const alertVariants = cva("rounded-lg p-3 text-sm", {
variants: {
variant: {
error: "bg-status-error-bg text-status-error-fg",
success: "bg-status-success-bg text-status-success-fg",
warning: "bg-status-warning-bg text-status-warning-fg",
info: "bg-status-info-bg text-status-info-fg",
},
},
defaultVariants: {
variant: "error",
},
});
type AlertProps = HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertVariants>;
const Alert = forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant, className }))}
{...props}
/>
),
);
Alert.displayName = "Alert";
export { Alert, alertVariants };
export type { AlertProps };

View File

@@ -0,0 +1,38 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils.ts";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
{
variants: {
variant: {
success: "bg-status-success-bg text-status-success-fg",
warning: "bg-status-warning-bg text-status-warning-fg",
error: "bg-status-error-bg text-status-error-fg",
info: "bg-status-info-bg text-status-info-fg",
neutral: "bg-mist text-muted-foreground",
},
},
defaultVariants: {
variant: "neutral",
},
},
);
type BadgeProps = HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof badgeVariants>;
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<span
ref={ref}
className={cn(badgeVariants({ variant, className }))}
{...props}
/>
),
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };
export type { BadgeProps };

View File

@@ -0,0 +1,49 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-copper underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
export type { ButtonProps };

View File

@@ -0,0 +1,55 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/utils.ts";
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border border-border bg-card shadow-sm", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardContent, CardFooter };

View File

@@ -0,0 +1,22 @@
import { cn } from "../../lib/utils.ts";
interface EmptyStateProps {
message: string;
className?: string;
}
function EmptyState({ message, className }: EmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border-subtle p-8 text-center",
className,
)}
>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
export { EmptyState };
export type { EmptyStateProps };

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.ts";
interface FormFieldProps {
label: string;
htmlFor?: string;
error?: string;
children: ReactNode;
className?: string;
}
function FormField({ label, htmlFor, error, children, className }: FormFieldProps) {
return (
<div className={cn("space-y-1.5", className)}>
<label htmlFor={htmlFor} className="text-sm font-medium text-foreground">{label}</label>
{children}
{error && <p className="text-sm text-status-error-fg">{error}</p>}
</div>
);
}
export { FormField };
export type { FormFieldProps };

View File

@@ -0,0 +1,19 @@
import { type InputHTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/utils.ts";
const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,20 @@
import { cn } from "../../lib/utils.ts";
interface LoadingStateProps {
message?: string;
className?: string;
}
function LoadingState({
message = "Loading...",
className,
}: LoadingStateProps) {
return (
<div className={cn("py-12 text-center", className)}>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
export { LoadingState };
export type { LoadingStateProps };

View File

@@ -0,0 +1,46 @@
import { cn } from "../../lib/utils.ts";
interface MetricCardComparison {
previous: string;
direction: "up" | "down";
}
interface MetricCardProps {
label: string;
value: string | number;
comparison?: MetricCardComparison;
className?: string;
}
function MetricCard({ label, value, comparison, className }: MetricCardProps) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card p-6 shadow-sm",
className,
)}
>
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
{value}
</p>
{comparison && (
<p className="mt-1 text-sm text-muted-foreground">
<span
className={
comparison.direction === "up"
? "text-status-success-fg"
: "text-status-error-fg"
}
>
{comparison.direction === "up" ? "\u2191" : "\u2193"}
</span>{" "}
vs {comparison.previous}
</p>
)}
</div>
);
}
export { MetricCard };
export type { MetricCardProps, MetricCardComparison };

View File

@@ -0,0 +1,50 @@
import { type ReactNode, useEffect } from "react";
import { cn } from "../../lib/utils.ts";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
className?: string;
}
function Modal({ open, onClose, title, children, className }: ModalProps) {
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-foreground/40"
onClick={onClose}
aria-hidden="true"
/>
{/* Card */}
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
"relative z-10 w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg",
className,
)}
>
<h2 className="font-heading text-lg font-semibold">{title}</h2>
<div className="mt-4">{children}</div>
</div>
</div>
);
}
export { Modal };
export type { ModalProps };

View File

@@ -0,0 +1,19 @@
import { type SelectHTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/utils.ts";
const Select = forwardRef<
HTMLSelectElement,
SelectHTMLAttributes<HTMLSelectElement>
>(({ className, ...props }, ref) => (
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Select.displayName = "Select";
export { Select };

View File

@@ -0,0 +1,43 @@
import { cn } from "../../lib/utils.ts";
interface TabOption {
value: string;
label: string;
}
interface TabGroupProps {
options: TabOption[];
value: string;
onChange: (value: string) => void;
className?: string;
}
function TabGroup({ options, value, onChange, className }: TabGroupProps) {
return (
<div
className={cn(
"inline-flex rounded-md border border-border bg-mist p-0.5",
className,
)}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"rounded-sm px-3 py-1.5 text-sm font-medium transition-colors",
value === option.value
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
export { TabGroup };
export type { TabGroupProps, TabOption };

View File

@@ -0,0 +1,19 @@
import { type TextareaHTMLAttributes, forwardRef } from "react";
import { cn } from "../../lib/utils.ts";
const Textarea = forwardRef<
HTMLTextAreaElement,
TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -0,0 +1,137 @@
/**
* UI extension registry for the open-core architecture.
*
* Provides registration hooks that allow enterprise/commercial code to
* inject additional tabs, pages, and navigation items into the admin UI
* without the core needing any direct knowledge of the extensions.
*
* In community edition (CE) mode the registry is simply empty and the
* UI renders only the built-in tabs/pages.
*/
import type { ComponentType } from 'react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
/** A tab injected into the site-detail page. */
export interface TabExtension {
/** Unique identifier used as the tab key (e.g. ``"ee-analytics"``). */
id: string;
/** Human-readable label shown in the tab bar. */
label: string;
/**
* React component rendered when the tab is active.
*
* Receives the same props that core tabs receive so extensions can
* access the current site and config.
*/
component: ComponentType<SiteDetailTabProps>;
/** Optional sort order — higher values appear further right. Core tabs use 0100. */
order?: number;
}
/** Props forwarded to every site-detail tab (core and extension). */
export interface SiteDetailTabProps {
siteId: string;
site: unknown;
config: unknown;
}
/** A page injected into the main router. */
export interface PageExtension {
/** Route path (e.g. ``"/ee/billing"``). */
path: string;
/** React component rendered at this route. */
component: ComponentType;
/** Whether the page requires authentication (default ``true``). */
protected?: boolean;
}
/** A navigation item injected into the top nav bar. */
export interface NavExtension {
/** Route path the link points to. */
path: string;
/** Human-readable label. */
label: string;
/** Optional sort order — higher values appear further right. Core items use 0100. */
order?: number;
}
/* ------------------------------------------------------------------ */
/* Internal state */
/* ------------------------------------------------------------------ */
const _tabs: TabExtension[] = [];
const _pages: PageExtension[] = [];
const _navItems: NavExtension[] = [];
/* ------------------------------------------------------------------ */
/* Registration API */
/* ------------------------------------------------------------------ */
/** Register an additional tab on the site-detail page. */
export function registerSiteDetailTab(tab: TabExtension): void {
if (!_tabs.some((t) => t.id === tab.id)) {
_tabs.push(tab);
}
}
/** Register an additional page/route. */
export function registerPage(page: PageExtension): void {
if (!_pages.some((p) => p.path === page.path)) {
_pages.push(page);
}
}
/** Register an additional top-nav item. */
export function registerNavItem(item: NavExtension): void {
if (!_navItems.some((n) => n.path === item.path)) {
_navItems.push(item);
}
}
/* ------------------------------------------------------------------ */
/* Query API */
/* ------------------------------------------------------------------ */
/** Return all registered site-detail tabs, sorted by order. */
export function getSiteDetailTabs(): readonly TabExtension[] {
return [..._tabs].sort((a, b) => (a.order ?? 200) - (b.order ?? 200));
}
/** Return all registered pages. */
export function getPages(): readonly PageExtension[] {
return [..._pages];
}
/** Return all registered nav items, sorted by order. */
export function getNavItems(): readonly NavExtension[] {
return [..._navItems].sort((a, b) => (a.order ?? 200) - (b.order ?? 200));
}
/* ------------------------------------------------------------------ */
/* Discovery */
/* ------------------------------------------------------------------ */
/**
* Attempt to load enterprise UI extensions.
*
* In the OSS repo this is a no-op. In the cloud repo, the build
* system replaces the virtual module ``virtual:ee-extensions`` with
* the actual EE register module, enabling extension discovery.
*/
export async function discoverExtensions(): Promise<void> {
try {
// The virtual module is provided by the EE Vite plugin in the
// cloud repo. In OSS builds the import fails and we fall through
// to the catch block silently.
const mod = await import('virtual:ee-extensions');
if (mod) {
console.info('[CMP] Enterprise UI extensions loaded');
}
} catch {
// No EE extensions available — running in community edition mode.
}
}

124
apps/admin-ui/src/index.css Normal file
View File

@@ -0,0 +1,124 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@fontsource/dm-sans/300.css";
@import "@fontsource/dm-sans/400.css";
@import "@fontsource/dm-sans/500.css";
@import "@fontsource/sora/400.css";
@import "@fontsource/sora/600.css";
@import "@fontsource/sora/700.css";
/*
ConsentOS palette (see assets/brand/README.md for the canonical
reference). Hex values are encoded as-is via Tailwind v4's @theme
directive — no oklch conversion needed since shadcn/ui consumes
the CSS variables directly.
*/
:root {
/* Surfaces & Backgrounds */
--background: #FFFFFF; /* page background */
--foreground: #0E1929; /* primary text — Ink */
--card: #FFFFFF;
--mist: #EEF3FF; /* Blue tint */
--surface: #F5F8FC; /* Surface */
/* Accent colours */
--primary: #1B3C7C; /* Navy — primary brand */
--primary-foreground: #FFFFFF;
--action: #2C6AE4; /* Blue — action / CTA */
--accent-mid: #4D8AFF; /* Blue mid — accent / highlight */
/* Borders */
--border: #DDE6F4;
--border-subtle: #EEF3FF;
--fog: #C4D5FA;
--input: #DDE6F4;
--ring: #2C6AE4;
/* Text hierarchy */
--text-secondary: #5A6E96; /* Slate */
--text-tertiary: #96AECE; /* Light slate */
--muted-foreground: #5A6E96;
/* Status colours */
--status-success-fg: #0DAA72; /* Consent green */
--status-success-bg: #E6F8F1;
--status-warning-fg: #B45309;
--status-warning-bg: #FEF3C7;
--status-error-fg: #B91C1C;
--status-error-bg: #FEE2E2;
--status-info-fg: #1B3C7C;
--status-info-bg: #EEF3FF;
/* Destructive */
--destructive: #DC2626;
--destructive-foreground: #FFFFFF;
/* Secondary */
--secondary: #EEF3FF; /* Blue tint */
--secondary-foreground: #1B3C7C;
/* Accent (for hover states) */
--accent: #EEF3FF;
--accent-foreground: #1B3C7C;
/* Radius */
--radius: 0.5rem;
}
@theme {
/* Pencil design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-mist: var(--mist);
--color-surface: var(--surface);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-action: var(--action);
--color-accent-mid: var(--accent-mid);
/* Backwards-compat alias: legacy "copper" token now maps to the
ConsentOS action blue. New code should use ``--color-action``. */
--color-copper: var(--action);
--color-border: var(--border);
--color-border-subtle: var(--border-subtle);
--color-fog: var(--fog);
--color-input: var(--input);
--color-ring: var(--ring);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-muted-foreground: var(--muted-foreground);
--color-status-success-fg: var(--status-success-fg);
--color-status-success-bg: var(--status-success-bg);
--color-status-warning-fg: var(--status-warning-fg);
--color-status-warning-bg: var(--status-warning-bg);
--color-status-error-fg: var(--status-error-fg);
--color-status-error-bg: var(--status-error-bg);
--color-status-info-fg: var(--status-info-fg);
--color-status-info-bg: var(--status-info-bg);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
/* Typography */
--font-heading: "Sora", system-ui, sans-serif;
--font-sans: "DM Sans", system-ui, sans-serif;
/* Border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
body {
@apply bg-background text-foreground antialiased font-sans;
margin: 0;
min-height: 100vh;
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from './components/ErrorBoundary'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)

View File

@@ -0,0 +1,628 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { getComplianceScoreSummary, getComplianceScoreTrend } from '../api/compliance-scores';
import { listSites } from '../api/sites';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { EmptyState } from '../components/ui/empty-state';
import { LoadingState } from '../components/ui/loading-state';
import { Select } from '../components/ui/select';
import { TabGroup } from '../components/ui/tab-group';
import type {
ComplianceScoreSummary,
ComplianceScoreTrendPoint,
ComplianceScoreTrendResponse,
ComplianceStatus,
Site,
} from '../types/api';
// ── Constants ────────────────────────────────────────────────────────
type DateRange = '7d' | '30d' | '90d' | '12m';
const DATE_RANGE_OPTIONS: { value: DateRange; label: string; days: number }[] = [
{ value: '7d', label: '7 days', days: 7 },
{ value: '30d', label: '30 days', days: 30 },
{ value: '90d', label: '90 days', days: 90 },
{ value: '12m', label: '12 months', days: 365 },
];
const FRAMEWORK_COLOURS: Record<string, string> = {
gdpr: '#3b82f6',
cnil: '#8b5cf6',
ccpa: '#f59e0b',
eprivacy: '#10b981',
lgpd: '#ef4444',
};
const FRAMEWORK_LABELS: Record<string, string> = {
gdpr: 'GDPR',
cnil: 'CNIL',
ccpa: 'CCPA/CPRA',
eprivacy: 'ePrivacy',
lgpd: 'LGPD',
};
type SeverityFilter = 'all' | 'critical' | 'warning' | 'info';
// ── Score change indicator ───────────────────────────────────────────
function ScoreChange({ current, previous }: { current: number; previous: number | null }) {
if (previous === null) return <span className="text-xs text-text-tertiary">No prior data</span>;
const diff = current - previous;
if (diff === 0) return <span className="text-xs text-text-tertiary">No change</span>;
const isPositive = diff > 0;
return (
<span className={`text-xs font-medium ${isPositive ? 'text-status-success-fg' : 'text-status-error-fg'}`}>
{isPositive ? '+' : ''}{diff} vs yesterday
</span>
);
}
// ── Overview panel ───────────────────────────────────────────────────
function OverviewPanel({
summary,
trendData,
}: {
summary: ComplianceScoreSummary;
trendData: ComplianceScoreTrendResponse | undefined;
}) {
// Calculate previous day scores for each framework from trend data
const previousScores = useMemo(() => {
if (!trendData?.data_points) return new Map<string, number>();
const map = new Map<string, number>();
const byFramework = new Map<string, ComplianceScoreTrendPoint[]>();
for (const dp of trendData.data_points) {
const list = byFramework.get(dp.framework) ?? [];
list.push(dp);
byFramework.set(dp.framework, list);
}
for (const [fw, points] of byFramework) {
// Sort by date descending, take second entry as "previous"
const sorted = [...points].sort(
(a, b) => new Date(b.scanned_at).getTime() - new Date(a.scanned_at).getTime(),
);
if (sorted.length > 1) {
map.set(fw, sorted[1].score);
}
}
return map;
}, [trendData]);
const scoreBadgeVariant = (score: number): 'success' | 'warning' | 'error' =>
score > 90 ? 'success' : score >= 70 ? 'warning' : 'error';
const statusBadgeVariant = (status: ComplianceStatus): 'success' | 'warning' | 'error' => {
const map: Record<ComplianceStatus, 'success' | 'warning' | 'error'> = {
compliant: 'success',
partial: 'warning',
non_compliant: 'error',
};
return map[status];
};
const statusLabels: Record<ComplianceStatus, string> = {
compliant: 'Compliant',
partial: 'Partial',
non_compliant: 'Non-compliant',
};
return (
<Card className="p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-heading text-sm font-semibold text-foreground">Overall Compliance</h3>
<Badge variant={scoreBadgeVariant(summary.overall_score)} className="text-lg font-bold px-3 py-1">
{summary.overall_score}
</Badge>
</div>
{summary.frameworks.length === 0 ? (
<p className="text-sm text-text-secondary">No compliance scores recorded yet. Scores are computed daily.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{summary.frameworks.map((fw) => (
<div key={fw.framework} className="rounded-md border border-border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{FRAMEWORK_LABELS[fw.framework] ?? fw.framework}
</span>
<Badge variant={scoreBadgeVariant(fw.score)} className="text-lg font-bold px-3 py-1">
{fw.score}
</Badge>
</div>
<div className="mt-1 flex items-center justify-between">
<Badge variant={statusBadgeVariant(fw.status)}>
{statusLabels[fw.status]}
</Badge>
<ScoreChange
current={fw.score}
previous={previousScores.get(fw.framework) ?? null}
/>
</div>
<div className="mt-2 text-xs text-text-secondary">
{fw.critical_count > 0 && (
<span className="mr-2 text-status-error-fg">{fw.critical_count} critical</span>
)}
{fw.warning_count > 0 && (
<span className="mr-2 text-status-warning-fg">{fw.warning_count} warning</span>
)}
{fw.info_count > 0 && (
<span className="text-status-info-fg">{fw.info_count} info</span>
)}
</div>
</div>
))}
</div>
)}
</Card>
);
}
// ── Trend chart ──────────────────────────────────────────────────────
interface ChartDataPoint {
date: string;
[framework: string]: string | number;
}
function TrendChart({
trendData,
dateRange,
onDateRangeChange,
}: {
trendData: ComplianceScoreTrendResponse | undefined;
dateRange: DateRange;
onDateRangeChange: (range: DateRange) => void;
}) {
const chartData = useMemo(() => {
if (!trendData?.data_points || trendData.data_points.length === 0) return [];
// Group by date, with one key per framework
const byDate = new Map<string, ChartDataPoint>();
for (const dp of trendData.data_points) {
const dateKey = new Date(dp.scanned_at).toISOString().split('T')[0];
const existing = byDate.get(dateKey) ?? { date: dateKey };
existing[dp.framework] = dp.score;
byDate.set(dateKey, existing);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}, [trendData]);
const frameworks = useMemo(() => {
if (!trendData?.data_points) return [];
return [...new Set(trendData.data_points.map((dp) => dp.framework))];
}, [trendData]);
const tabOptions = DATE_RANGE_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }));
return (
<Card className="p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-heading text-sm font-semibold text-foreground">Score Trends</h3>
<TabGroup
options={tabOptions}
value={dateRange}
onChange={(v) => onDateRangeChange(v as DateRange)}
/>
</div>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-text-secondary">
No trend data available for this period.
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
tickFormatter={(v: string) => {
const d = new Date(v);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
}}
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
<Tooltip
labelFormatter={(v) => new Date(String(v)).toLocaleDateString('en-GB')}
formatter={(value: unknown, name: unknown) => [
`${String(value)}/100`,
FRAMEWORK_LABELS[String(name)] ?? String(name),
]}
/>
<Legend
formatter={(value: string) => FRAMEWORK_LABELS[value] ?? value}
/>
{frameworks.map((fw) => (
<Line
key={fw}
type="monotone"
dataKey={fw}
stroke={FRAMEWORK_COLOURS[fw] ?? '#6b7280'}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</Card>
);
}
// ── Issues table ─────────────────────────────────────────────────────
interface FlatIssue {
framework: string;
rule_id: string;
severity: string;
message: string;
recommendation: string;
scanned_at: string;
}
function IssuesTable({ summary }: { summary: ComplianceScoreSummary }) {
const [frameworkFilter, setFrameworkFilter] = useState<string>('all');
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
const [sortField, setSortField] = useState<'framework' | 'severity' | 'scanned_at'>('severity');
const [sortAsc, setSortAsc] = useState(true);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
// Flatten issues from all frameworks
const allIssues = useMemo(() => {
const issues: FlatIssue[] = [];
for (const fw of summary.frameworks) {
if (!fw.issues) continue;
const issueList = Array.isArray(fw.issues) ? fw.issues : Object.values(fw.issues);
for (const issue of issueList as Array<{
rule_id?: string;
severity?: string;
message?: string;
recommendation?: string;
}>) {
issues.push({
framework: fw.framework,
rule_id: issue.rule_id ?? 'unknown',
severity: issue.severity ?? 'info',
message: issue.message ?? '',
recommendation: issue.recommendation ?? '',
scanned_at: fw.scanned_at,
});
}
}
return issues;
}, [summary]);
const filteredIssues = useMemo(() => {
let result = allIssues;
if (frameworkFilter !== 'all') {
result = result.filter((i) => i.framework === frameworkFilter);
}
if (severityFilter !== 'all') {
result = result.filter((i) => i.severity === severityFilter);
}
const severityOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
result.sort((a, b) => {
let cmp: number;
if (sortField === 'severity') {
cmp = (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3);
} else if (sortField === 'framework') {
cmp = a.framework.localeCompare(b.framework);
} else {
cmp = new Date(a.scanned_at).getTime() - new Date(b.scanned_at).getTime();
}
return sortAsc ? cmp : -cmp;
});
return result;
}, [allIssues, frameworkFilter, severityFilter, sortField, sortAsc]);
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortAsc(!sortAsc);
} else {
setSortField(field);
setSortAsc(true);
}
};
const frameworks = useMemo(
() => [...new Set(allIssues.map((i) => i.framework))],
[allIssues],
);
const severityVariant: Record<string, 'error' | 'warning' | 'info' | 'neutral'> = {
critical: 'error',
warning: 'warning',
info: 'info',
};
if (allIssues.length === 0) {
return (
<Card className="p-6">
<h3 className="font-heading text-sm font-semibold text-foreground mb-2">Issues</h3>
<p className="text-sm text-text-secondary">No compliance issues detected. Well done!</p>
</Card>
);
}
return (
<Card className="p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-heading text-sm font-semibold text-foreground">
Issues ({filteredIssues.length})
</h3>
<div className="flex gap-2">
<Select
value={frameworkFilter}
onChange={(e) => setFrameworkFilter(e.target.value)}
className="h-8 px-2 py-1 text-xs"
>
<option value="all">All frameworks</option>
{frameworks.map((fw) => (
<option key={fw} value={fw}>
{FRAMEWORK_LABELS[fw] ?? fw}
</option>
))}
</Select>
<Select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as SeverityFilter)}
className="h-8 px-2 py-1 text-xs"
>
<option value="all">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</Select>
</div>
</div>
<div className="overflow-hidden rounded-md border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-mist">
<tr>
<th
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
onClick={() => handleSort('framework')}
>
Framework {sortField === 'framework' ? (sortAsc ? '▲' : '▼') : ''}
</th>
<th
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
onClick={() => handleSort('severity')}
>
Severity {sortField === 'severity' ? (sortAsc ? '▲' : '▼') : ''}
</th>
<th className="px-3 py-2 text-left font-medium text-text-secondary">Description</th>
<th
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
onClick={() => handleSort('scanned_at')}
>
Detected {sortField === 'scanned_at' ? (sortAsc ? '▲' : '▼') : ''}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredIssues.map((issue, idx) => {
const rowKey = `${issue.framework}-${issue.rule_id}-${idx}`;
const isExpanded = expandedRow === rowKey;
return (
<tr
key={rowKey}
className="cursor-pointer hover:bg-mist"
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
>
<td className="px-3 py-2 font-medium text-foreground">
{FRAMEWORK_LABELS[issue.framework] ?? issue.framework}
</td>
<td className="px-3 py-2">
<Badge variant={severityVariant[issue.severity] ?? 'neutral'} className="text-xs font-semibold">
{issue.severity}
</Badge>
</td>
<td className="px-3 py-2 text-text-secondary">
<div>{issue.message}</div>
{isExpanded && (
<div className="mt-2 rounded bg-mist p-2 text-xs text-text-secondary">
<p className="font-medium text-foreground">Recommendation:</p>
<p>{issue.recommendation}</p>
<p className="mt-1 font-mono text-text-tertiary">{issue.rule_id}</p>
</div>
)}
</td>
<td className="px-3 py-2 text-text-secondary">
{new Date(issue.scanned_at).toLocaleDateString('en-GB')}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}
// ── Export functions ──────────────────────────────────────────────────
function exportAsJson(summary: ComplianceScoreSummary): void {
const report = {
exported_at: new Date().toISOString(),
site_id: summary.site_id,
overall_score: summary.overall_score,
frameworks: summary.frameworks.map((fw) => ({
framework: fw.framework,
score: fw.score,
status: fw.status,
critical_count: fw.critical_count,
warning_count: fw.warning_count,
info_count: fw.info_count,
issues: fw.issues,
scanned_at: fw.scanned_at,
})),
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `compliance-report-${summary.site_id}-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function exportAsCsv(summary: ComplianceScoreSummary): void {
const rows: string[] = [
'Framework,Score,Status,Critical,Warning,Info,Scanned At',
];
for (const fw of summary.frameworks) {
rows.push(
[
FRAMEWORK_LABELS[fw.framework] ?? fw.framework,
fw.score,
fw.status,
fw.critical_count,
fw.warning_count,
fw.info_count,
fw.scanned_at,
].join(','),
);
}
const blob = new Blob([rows.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `compliance-report-${summary.site_id}-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// ── Main page component ──────────────────────────────────────────────
export default function ComplianceDashboardPage() {
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<DateRange>('90d');
const days = DATE_RANGE_OPTIONS.find((opt) => opt.value === dateRange)?.days ?? 90;
// Fetch sites for the selector
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
queryKey: ['sites'],
queryFn: listSites,
});
// Auto-select the first site when sites load
const effectiveSiteId = selectedSiteId ?? sites?.[0]?.id ?? null;
const { data: summary, isLoading: summaryLoading } = useQuery<ComplianceScoreSummary>({
queryKey: ['compliance-scores', effectiveSiteId],
queryFn: () => getComplianceScoreSummary(effectiveSiteId!),
enabled: !!effectiveSiteId,
});
const { data: trendData } = useQuery<ComplianceScoreTrendResponse>({
queryKey: ['compliance-scores', 'trend', effectiveSiteId, days],
queryFn: () => getComplianceScoreTrend(effectiveSiteId!, { days }),
enabled: !!effectiveSiteId,
});
const handleSiteChange = useCallback((siteId: string) => {
setSelectedSiteId(siteId);
}, []);
if (sitesLoading) {
return <LoadingState />;
}
if (!sites || sites.length === 0) {
return (
<EmptyState message="No sites configured. Add a site first." />
);
}
return (
<div>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Compliance Dashboard</h1>
<p className="mt-1 text-sm text-text-secondary">
Continuous compliance monitoring with daily score tracking.
</p>
</div>
<div className="flex items-center gap-3">
{/* Site selector */}
<Select
value={effectiveSiteId ?? ''}
onChange={(e) => handleSiteChange(e.target.value)}
>
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.display_name || site.domain}
</option>
))}
</Select>
{/* Export buttons */}
{summary && (
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => exportAsJson(summary)}
>
Export JSON
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportAsCsv(summary)}
>
Export CSV
</Button>
</div>
)}
</div>
</div>
{summaryLoading ? (
<LoadingState message="Loading compliance data..." />
) : !summary ? (
<EmptyState message="No compliance data available for this site. Scores are computed daily at 04:00 UTC." />
) : (
<div className="space-y-6">
{/* Overview panel */}
<OverviewPanel summary={summary} trendData={trendData} />
{/* Score trend chart */}
<TrendChart
trendData={trendData}
dateRange={dateRange}
onDateRangeChange={setDateRange}
/>
{/* Issues table */}
<IssuesTable summary={summary} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
import { Button } from '../components/ui/button.tsx';
import { Input } from '../components/ui/input.tsx';
import { FormField } from '../components/ui/form-field.tsx';
import { Alert } from '../components/ui/alert.tsx';
import { Card, CardContent } from '../components/ui/card.tsx';
export default function LoginPage() {
const { isAuthenticated, isLoading, login } = useAuthStore();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
if (isAuthenticated) {
return <Navigate to="/sites" replace />;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/sites');
} catch {
setError('Invalid email or password');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md">
<Card>
<CardContent className="px-8 py-10">
<div className="mb-2 flex items-center justify-center gap-3">
<img src="/logo-mark.svg" alt="" width="32" height="32" aria-hidden="true" />
<h1 className="font-heading text-2xl font-semibold text-foreground">
<span className="text-primary">Consent</span>
<span className="text-action">OS</span>
</h1>
</div>
<p className="mb-8 text-center text-sm text-text-secondary">
Sign in to manage your consent platform
</p>
<form onSubmit={handleSubmit} className="space-y-5">
{error && <Alert variant="error">{error}</Alert>}
<FormField label="Email address" htmlFor="email">
<Input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</FormField>
<FormField label="Password" htmlFor="password">
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
/>
</FormField>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,386 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { getOrgConfig, updateOrgConfig } from '../api/org-config';
import { trackConfigChange } from '../services/analytics';
import BannerBuilderTab from '../components/BannerBuilderTab';
import { Alert } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { FormField } from '../components/ui/form-field';
import { Input } from '../components/ui/input';
import { LoadingState } from '../components/ui/loading-state';
import { Select } from '../components/ui/select';
const GPP_SECTIONS = [
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
];
const GPC_JURISDICTIONS = [
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
{ value: 'US-CO', label: 'Colorado (CPA)' },
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
{ value: 'US-TX', label: 'Texas (TDPSA)' },
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
];
type Tab = 'configuration' | 'banner';
export default function SettingsPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>('configuration');
const [saved, setSaved] = useState(false);
const { data: config, isLoading } = useQuery({
queryKey: ['org-config'],
queryFn: getOrgConfig,
});
// ── Form state (all nullable for org-level tri-state) ──────────────
const [blockingMode, setBlockingMode] = useState<string>('');
const [tcfEnabled, setTcfEnabled] = useState<string>('');
const [gcmEnabled, setGcmEnabled] = useState<string>('');
const [shopifyEnabled, setShopifyEnabled] = useState<string>('');
const [consentExpiry, setConsentExpiry] = useState<string>('');
const [privacyUrl, setPrivacyUrl] = useState<string>('');
const [termsUrl, setTermsUrl] = useState<string>('');
// GPP state
const [gppEnabled, setGppEnabled] = useState<string>('');
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>([]);
// GPC state
const [gpcEnabled, setGpcEnabled] = useState<string>('');
const [gpcGlobalHonour, setGpcGlobalHonour] = useState<string>('');
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>([]);
// Sync local state when config loads
const [initialised, setInitialised] = useState(false);
if (config && !initialised) {
setBlockingMode(config.blocking_mode ?? '');
setTcfEnabled(config.tcf_enabled === null ? '' : config.tcf_enabled ? 'true' : 'false');
setGcmEnabled(config.gcm_enabled === null ? '' : config.gcm_enabled ? 'true' : 'false');
setShopifyEnabled(config.shopify_privacy_enabled === null ? '' : config.shopify_privacy_enabled ? 'true' : 'false');
setConsentExpiry(config.consent_expiry_days?.toString() ?? '');
setPrivacyUrl(config.privacy_policy_url ?? '');
setTermsUrl(config.terms_url ?? '');
setGppEnabled(config.gpp_enabled === null ? '' : config.gpp_enabled ? 'true' : 'false');
setGppSupportedApis(config.gpp_supported_apis ?? []);
setGpcEnabled(config.gpc_enabled === null ? '' : config.gpc_enabled ? 'true' : 'false');
setGpcGlobalHonour(config.gpc_global_honour === null ? '' : config.gpc_global_honour ? 'true' : 'false');
setGpcJurisdictions(config.gpc_jurisdictions ?? []);
setInitialised(true);
}
const mutation = useMutation({
mutationFn: updateOrgConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org-config'] });
trackConfigChange('org_config');
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({
blocking_mode: (blockingMode || null) as 'opt_in' | 'opt_out' | 'informational' | null,
tcf_enabled: tcfEnabled === '' ? null : tcfEnabled === 'true',
gcm_enabled: gcmEnabled === '' ? null : gcmEnabled === 'true',
shopify_privacy_enabled: shopifyEnabled === '' ? null : shopifyEnabled === 'true',
consent_expiry_days: consentExpiry === '' ? null : Number(consentExpiry),
privacy_policy_url: privacyUrl || null,
terms_url: termsUrl || null,
gpp_enabled: gppEnabled === '' ? null : gppEnabled === 'true',
gpp_supported_apis: gppEnabled === 'true' && gppSupportedApis.length > 0 ? gppSupportedApis : null,
gpc_enabled: gpcEnabled === '' ? null : gpcEnabled === 'true',
gpc_global_honour: gpcGlobalHonour === '' ? null : gpcGlobalHonour === 'true',
gpc_jurisdictions: gpcEnabled === 'true' && gpcJurisdictions.length > 0 ? gpcJurisdictions : null,
});
};
const toggleGppSection = (api: string) => {
setGppSupportedApis((prev) =>
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
);
};
const toggleGpcJurisdiction = (code: string) => {
setGpcJurisdictions((prev) =>
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
);
};
if (isLoading) {
return <LoadingState />;
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'configuration', label: 'Configuration' },
{ key: 'banner', label: 'Banner' },
];
return (
<div>
<div className="mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Organisation settings</h1>
<p className="mt-1 text-sm text-text-secondary">
Set default configuration for all sites in your organisation. Individual sites can
override these values.
</p>
</div>
{/* Tabs */}
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
activeTab === tab.key
? 'border-copper font-medium text-foreground'
: 'border-transparent text-text-secondary hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="mt-6">
{activeTab === 'configuration' && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Cascade explanation banner */}
<div className="rounded-lg border border-dashed border-border bg-background p-4">
<p className="text-xs text-text-secondary">
<strong>Configuration cascade:</strong> System defaults Organisation defaults (this
page) Site group defaults Site-level config Regional overrides. Each level
only overrides fields that are explicitly set. Leave a field empty to inherit the
system default.
</p>
</div>
{/* Consent settings */}
<Card className="p-6">
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">Default consent settings</h3>
<p className="mb-4 text-xs text-text-secondary">
These defaults apply to all sites unless overridden at site or group level.
Leave a field empty to use the system default.
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<FormField label="Default blocking mode">
<Select
value={blockingMode}
onChange={(e) => setBlockingMode(e.target.value)}
>
<option value="">System default (opt-in)</option>
<option value="opt_in">Opt-in (GDPR)</option>
<option value="opt_out">Opt-out (CCPA)</option>
<option value="informational">Informational only</option>
</Select>
</FormField>
<FormField label="Default consent expiry (days)">
<Input
type="number"
min={1}
max={730}
value={consentExpiry}
onChange={(e) => setConsentExpiry(e.target.value)}
placeholder="System default (365)"
/>
</FormField>
<FormField label="Default privacy policy URL">
<Input
type="url"
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder="https://example.com/privacy"
/>
</FormField>
<FormField label="Default terms & conditions URL">
<Input
type="url"
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder="https://example.com/terms"
/>
</FormField>
</div>
</Card>
{/* Standards & integrations */}
<Card className="p-6">
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">Default standards &amp; integrations</h3>
<p className="mb-4 text-xs text-text-secondary">
Control whether standards and integrations are enabled by default across all sites.
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<FormField label="IAB TCF v2.2">
<Select
value={tcfEnabled}
onChange={(e) => setTcfEnabled(e.target.value)}
>
<option value="">System default (disabled)</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
<FormField label="Google Consent Mode v2">
<Select
value={gcmEnabled}
onChange={(e) => setGcmEnabled(e.target.value)}
>
<option value="">System default (enabled)</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
<FormField label="Shopify Customer Privacy API">
<Select
value={shopifyEnabled}
onChange={(e) => setShopifyEnabled(e.target.value)}
>
<option value="">System default (disabled)</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
</div>
</Card>
{/* IAB Global Privacy Platform (GPP) */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">IAB Global Privacy Platform (GPP)</h3>
<p className="mb-4 text-xs text-text-secondary">
GPP provides a standardised consent string format for US state privacy laws.
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP strings
for the selected sections.
</p>
<FormField label="Enable GPP">
<Select
value={gppEnabled}
onChange={(e) => setGppEnabled(e.target.value)}
>
<option value="">System default</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
{gppEnabled === 'true' && (
<div className="mt-4 space-y-2">
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
{GPP_SECTIONS.map((section) => (
<label key={section.value} className="flex items-center gap-2">
<input
type="checkbox"
checked={gppSupportedApis.includes(section.value)}
onChange={() => toggleGppSection(section.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{section.label}</span>
</label>
))}
</div>
)}
</Card>
{/* Global Privacy Control (GPC) */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Global Privacy Control (GPC)</h3>
<p className="mb-4 text-xs text-text-secondary">
GPC is a browser signal indicating a user&apos;s intent to opt out of the sale or
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
legally require businesses to honour this signal.
</p>
<div className="space-y-4">
<FormField label="Detect GPC signal">
<Select
value={gpcEnabled}
onChange={(e) => setGpcEnabled(e.target.value)}
>
<option value="">System default</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
{gpcEnabled === 'true' && (
<div className="space-y-4">
<FormField label="Honour globally">
<Select
value={gpcGlobalHonour}
onChange={(e) => setGpcGlobalHonour(e.target.value)}
>
<option value="">System default</option>
<option value="true">Enabled apply GPC opt-out for all visitors regardless of jurisdiction</option>
<option value="false">Disabled only honour in selected jurisdictions</option>
</Select>
</FormField>
{gpcGlobalHonour !== 'true' && (
<div>
<p className="mb-2 text-xs font-medium text-text-secondary">
Jurisdictions where GPC is legally required
</p>
{GPC_JURISDICTIONS.map((j) => (
<label key={j.value} className="flex items-center gap-2 py-0.5">
<input
type="checkbox"
checked={gpcJurisdictions.includes(j.value)}
onChange={() => toggleGpcJurisdiction(j.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{j.label}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
</Card>
<div className="flex items-center gap-3">
<Button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Save defaults'}
</Button>
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
{mutation.isError && (
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
)}
</div>
</form>
)}
{activeTab === 'banner' && config && (
<BannerBuilderTab
configQueryKey={['org-config']}
config={config}
onSave={(body) => updateOrgConfig(body)}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
import SiteComplianceTab from '../components/SiteComplianceTab';
import SiteConfigTab from '../components/SiteConfigTab';
import SiteCookiesTab from '../components/SiteCookiesTab';
import SiteOverviewTab from '../components/SiteOverviewTab';
import BannerBuilderTab from '../components/BannerBuilderTab';
import SiteScannerTab from '../components/SiteScannerTab';
import SiteTranslationsTab from '../components/SiteTranslationsTab';
import { LoadingState } from '../components/ui/loading-state.tsx';
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: 'cookies', label: 'Cookies', order: 30 },
{ id: 'banner', label: 'Banner', order: 40 },
{ id: 'translations', label: 'Translations', order: 50 },
{ id: 'scanner', label: 'Scans', order: 60 },
{ id: 'compliance', label: 'Compliance', order: 70 },
];
export default function SiteDetailPage() {
const { siteId } = useParams<{ siteId: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
const allTabs = useMemo(() => {
const ext = extensionTabs.map((t) => ({
id: t.id,
label: t.label,
order: t.order ?? 200,
}));
return [...CORE_TABS, ...ext].sort((a, b) => a.order - b.order);
}, [extensionTabs]);
const { data: site, isLoading: siteLoading } = useQuery({
queryKey: ['sites', siteId],
queryFn: () => getSite(siteId!),
enabled: !!siteId,
});
const { data: config, isLoading: configLoading } = useQuery({
queryKey: ['sites', siteId, 'config'],
queryFn: () => getSiteConfig(siteId!),
enabled: !!siteId,
});
if (siteLoading || configLoading) {
return <LoadingState />;
}
if (!site) {
return <div className="py-12 text-center text-sm text-status-error-fg">Site not found</div>;
}
return (
<div>
{/* Header */}
<div className="mb-4 sm:mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
{site.display_name ?? site.name ?? site.domain}
</h1>
<p className="mt-1 text-sm text-text-secondary">{site.domain}</p>
</div>
{/* Tabs — horizontally scrollable on mobile, copper underline */}
<div className="mb-4 sm:mb-6">
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
{allTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
activeTab === tab.id
? 'border-copper font-medium text-foreground'
: 'border-transparent text-text-secondary hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab content — core tabs */}
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
{activeTab === 'banner' && siteId && (
<BannerBuilderTab
configQueryKey={['sites', siteId, 'config']}
config={config ?? null}
onSave={(body) => updateSiteConfig(siteId, body)}
siteDomain={site.domain}
/>
)}
{activeTab === 'translations' && siteId && <SiteTranslationsTab siteId={siteId} />}
{activeTab === 'scanner' && siteId && <SiteScannerTab siteId={siteId} />}
{activeTab === 'compliance' && siteId && <SiteComplianceTab siteId={siteId} config={config ?? null} />}
{/* Extension tabs */}
{extensionTabs.map(
(ext) =>
activeTab === ext.id &&
siteId && (
<ext.component
key={ext.id}
siteId={siteId}
site={site}
config={config ?? null}
/>
),
)}
</div>
);
}

View File

@@ -0,0 +1,420 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useParams } from 'react-router-dom';
import { getSiteGroup } from '../api/site-groups';
import { getSiteGroupConfig, updateSiteGroupConfig } from '../api/site-group-config';
import BannerBuilderTab from '../components/BannerBuilderTab';
import { Alert } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { FormField } from '../components/ui/form-field';
import { Input } from '../components/ui/input';
import { LoadingState } from '../components/ui/loading-state';
import { Select } from '../components/ui/select';
const GPP_SECTIONS = [
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
];
const GPC_JURISDICTIONS = [
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
{ value: 'US-CO', label: 'Colorado (CPA)' },
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
{ value: 'US-TX', label: 'Texas (TDPSA)' },
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
];
type Tab = 'configuration' | 'banner';
export default function SiteGroupDetailPage() {
const { groupId } = useParams<{ groupId: string }>();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>('configuration');
const [saved, setSaved] = useState(false);
const { data: group, isLoading: groupLoading } = useQuery({
queryKey: ['site-groups', groupId],
queryFn: () => getSiteGroup(groupId!),
enabled: !!groupId,
});
const { data: config, isLoading: configLoading } = useQuery({
queryKey: ['site-group-config', groupId],
queryFn: () => getSiteGroupConfig(groupId!),
enabled: !!groupId,
});
// ── Form state (all nullable — empty = inherit from org/system) ────
const [blockingMode, setBlockingMode] = useState<string>('');
const [tcfEnabled, setTcfEnabled] = useState<string>('');
const [gcmEnabled, setGcmEnabled] = useState<string>('');
const [shopifyEnabled, setShopifyEnabled] = useState<string>('');
const [consentExpiry, setConsentExpiry] = useState<string>('');
const [privacyUrl, setPrivacyUrl] = useState<string>('');
const [termsUrl, setTermsUrl] = useState<string>('');
// GPP state
const [gppEnabled, setGppEnabled] = useState<string>('');
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>([]);
// GPC state
const [gpcEnabled, setGpcEnabled] = useState<string>('');
const [gpcGlobalHonour, setGpcGlobalHonour] = useState<string>('');
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>([]);
// Sync local state when config loads
const [initialised, setInitialised] = useState(false);
if (config && !initialised) {
setBlockingMode(config.blocking_mode ?? '');
setTcfEnabled(config.tcf_enabled === null ? '' : config.tcf_enabled ? 'true' : 'false');
setGcmEnabled(config.gcm_enabled === null ? '' : config.gcm_enabled ? 'true' : 'false');
setShopifyEnabled(
config.shopify_privacy_enabled === null ? '' : config.shopify_privacy_enabled ? 'true' : 'false',
);
setConsentExpiry(config.consent_expiry_days?.toString() ?? '');
setPrivacyUrl(config.privacy_policy_url ?? '');
setTermsUrl(config.terms_url ?? '');
setGppEnabled(config.gpp_enabled === null || config.gpp_enabled === undefined ? '' : config.gpp_enabled ? 'true' : 'false');
setGppSupportedApis(config.gpp_supported_apis ?? []);
setGpcEnabled(config.gpc_enabled === null || config.gpc_enabled === undefined ? '' : config.gpc_enabled ? 'true' : 'false');
setGpcGlobalHonour(config.gpc_global_honour === null || config.gpc_global_honour === undefined ? '' : config.gpc_global_honour ? 'true' : 'false');
setGpcJurisdictions(config.gpc_jurisdictions ?? []);
setInitialised(true);
}
const mutation = useMutation({
mutationFn: (body: Record<string, unknown>) => updateSiteGroupConfig(groupId!, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['site-group-config', groupId] });
// Invalidate inheritance for all sites in this group
queryClient.invalidateQueries({ queryKey: ['sites'] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({
blocking_mode: blockingMode || null,
tcf_enabled: tcfEnabled === '' ? null : tcfEnabled === 'true',
gcm_enabled: gcmEnabled === '' ? null : gcmEnabled === 'true',
shopify_privacy_enabled: shopifyEnabled === '' ? null : shopifyEnabled === 'true',
consent_expiry_days: consentExpiry === '' ? null : Number(consentExpiry),
privacy_policy_url: privacyUrl || null,
terms_url: termsUrl || null,
gpp_enabled: gppEnabled === '' ? null : gppEnabled === 'true',
gpp_supported_apis: gppEnabled === 'true' && gppSupportedApis.length > 0 ? gppSupportedApis : null,
gpc_enabled: gpcEnabled === '' ? null : gpcEnabled === 'true',
gpc_global_honour: gpcGlobalHonour === '' ? null : gpcGlobalHonour === 'true',
gpc_jurisdictions: gpcEnabled === 'true' && gpcJurisdictions.length > 0 ? gpcJurisdictions : null,
});
};
const toggleGppSection = (api: string) => {
setGppSupportedApis((prev) =>
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
);
};
const toggleGpcJurisdiction = (code: string) => {
setGpcJurisdictions((prev) =>
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
);
};
if (groupLoading || configLoading) {
return <LoadingState />;
}
if (!group) {
return <div className="py-12 text-center text-sm text-status-error-fg">Group not found</div>;
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'configuration', label: 'Configuration' },
{ key: 'banner', label: 'Banner' },
];
return (
<div>
{/* Header */}
<div className="mb-4 sm:mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
{group.name}
</h1>
{group.description && (
<p className="mt-1 text-sm text-text-secondary">{group.description}</p>
)}
<p className="mt-1 text-xs text-text-secondary">
Site group &middot; {group.site_count} {group.site_count === 1 ? 'site' : 'sites'}
</p>
</div>
{/* Tabs */}
<div className="mb-4 sm:mb-6">
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
activeTab === tab.key
? 'border-copper font-medium text-foreground'
: 'border-transparent text-text-secondary hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab content */}
<div>
{activeTab === 'configuration' && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Cascade explanation banner */}
<div className="rounded-lg border border-dashed border-border bg-background p-4">
<p className="text-xs text-text-secondary">
<strong>Configuration cascade:</strong> System defaults &rarr; Organisation defaults
&rarr; <span className="font-semibold">Group defaults (this page)</span> &rarr;
Site-level config &rarr; Regional overrides. Each level only overrides fields that
are explicitly set. Leave a field empty to inherit from the organisation or system
default.
</p>
</div>
{/* Consent settings */}
<Card className="p-6">
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">
Default consent settings
</h3>
<p className="mb-4 text-xs text-text-secondary">
These defaults apply to all sites in this group unless overridden at site level.
Leave a field empty to inherit from the organisation or system default.
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<FormField label="Default blocking mode">
<Select
value={blockingMode}
onChange={(e) => setBlockingMode(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="opt_in">Opt-in (GDPR)</option>
<option value="opt_out">Opt-out (CCPA)</option>
<option value="informational">Informational only</option>
</Select>
</FormField>
<FormField label="Default consent expiry (days)">
<Input
type="number"
min={1}
max={730}
value={consentExpiry}
onChange={(e) => setConsentExpiry(e.target.value)}
placeholder="Inherit from org/system"
/>
</FormField>
<FormField label="Default privacy policy URL">
<Input
type="url"
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder="https://example.com/privacy"
/>
</FormField>
<FormField label="Default terms & conditions URL">
<Input
type="url"
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder="https://example.com/terms"
/>
</FormField>
</div>
</Card>
{/* Standards & integrations */}
<Card className="p-6">
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">
Default standards &amp; integrations
</h3>
<p className="mb-4 text-xs text-text-secondary">
Control whether standards and integrations are enabled by default for sites in this
group.
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<FormField label="IAB TCF v2.2">
<Select
value={tcfEnabled}
onChange={(e) => setTcfEnabled(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
<FormField label="Google Consent Mode v2">
<Select
value={gcmEnabled}
onChange={(e) => setGcmEnabled(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
<FormField label="Shopify Customer Privacy API">
<Select
value={shopifyEnabled}
onChange={(e) => setShopifyEnabled(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
</div>
</Card>
{/* IAB Global Privacy Platform (GPP) */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
IAB Global Privacy Platform (GPP)
</h3>
<p className="mb-4 text-xs text-text-secondary">
GPP provides a standardised consent string format for US state privacy laws.
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP
strings for the selected sections.
</p>
<FormField label="Enable GPP">
<Select
value={gppEnabled}
onChange={(e) => setGppEnabled(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
{gppEnabled === 'true' && (
<div className="mt-4 space-y-2">
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
{GPP_SECTIONS.map((section) => (
<label key={section.value} className="flex items-center gap-2">
<input
type="checkbox"
checked={gppSupportedApis.includes(section.value)}
onChange={() => toggleGppSection(section.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{section.label}</span>
</label>
))}
</div>
)}
</Card>
{/* Global Privacy Control (GPC) */}
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
Global Privacy Control (GPC)
</h3>
<p className="mb-4 text-xs text-text-secondary">
GPC is a browser signal indicating a user&apos;s intent to opt out of the sale or
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
legally require businesses to honour this signal.
</p>
<div className="space-y-4">
<FormField label="Detect GPC signal">
<Select
value={gpcEnabled}
onChange={(e) => setGpcEnabled(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</Select>
</FormField>
{gpcEnabled === 'true' && (
<div className="space-y-4">
<FormField label="Honour globally">
<Select
value={gpcGlobalHonour}
onChange={(e) => setGpcGlobalHonour(e.target.value)}
>
<option value="">Inherit from org/system</option>
<option value="true">Enabled apply GPC opt-out for all visitors regardless of jurisdiction</option>
<option value="false">Disabled only honour in selected jurisdictions</option>
</Select>
</FormField>
{gpcGlobalHonour !== 'true' && (
<div>
<p className="mb-2 text-xs font-medium text-text-secondary">
Jurisdictions where GPC is legally required
</p>
{GPC_JURISDICTIONS.map((j) => (
<label key={j.value} className="flex items-center gap-2 py-0.5">
<input
type="checkbox"
checked={gpcJurisdictions.includes(j.value)}
onChange={() => toggleGpcJurisdiction(j.value)}
className="h-4 w-4 rounded border-border text-primary"
/>
<span className="text-sm text-text-secondary">{j.label}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
</Card>
<div className="flex items-center gap-3">
<Button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Save defaults'}
</Button>
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
{mutation.isError && (
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
)}
</div>
</form>
)}
{activeTab === 'banner' && config && groupId && (
<BannerBuilderTab
configQueryKey={['site-group-config', groupId]}
config={config}
onSave={(body) => updateSiteGroupConfig(groupId, body)}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,447 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { createSiteGroup, deleteSiteGroup, listSiteGroups } from '../api/site-groups';
import { listSites, updateSite } from '../api/sites';
import CreateSiteModal from '../components/CreateSiteModal';
import { Button } from '../components/ui/button.tsx';
import { Badge } from '../components/ui/badge.tsx';
import { Modal } from '../components/ui/modal.tsx';
import { FormField } from '../components/ui/form-field.tsx';
import { Input } from '../components/ui/input.tsx';
import { Alert } from '../components/ui/alert.tsx';
import { EmptyState } from '../components/ui/empty-state.tsx';
import { LoadingState } from '../components/ui/loading-state.tsx';
import type { Site, SiteGroup } from '../types/api';
export default function SitesPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [createGroupError, setCreateGroupError] = useState('');
const { data: sites, isLoading: sitesLoading, error: sitesError } = useQuery({
queryKey: ['sites'],
queryFn: listSites,
});
const { data: groups, isLoading: groupsLoading } = useQuery({
queryKey: ['site-groups'],
queryFn: listSiteGroups,
});
const createGroupMutation = useMutation({
mutationFn: createSiteGroup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
setShowCreateGroup(false);
setNewGroupName('');
setCreateGroupError('');
},
onError: () => {
setCreateGroupError('Failed to create group. Name may already exist.');
},
});
const deleteGroupMutation = useMutation({
mutationFn: deleteSiteGroup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
queryClient.invalidateQueries({ queryKey: ['sites'] });
},
});
const assignGroupMutation = useMutation({
mutationFn: ({ siteId, groupId }: { siteId: string; groupId: string | null }) =>
updateSite(siteId, { site_group_id: groupId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
},
});
// Group sites by site_group_id
const { groupedSites, ungroupedSites } = useMemo(() => {
if (!sites) return { groupedSites: new Map<string, Site[]>(), ungroupedSites: [] };
const grouped = new Map<string, Site[]>();
const ungrouped: Site[] = [];
for (const site of sites) {
if (site.site_group_id) {
const list = grouped.get(site.site_group_id) ?? [];
list.push(site);
grouped.set(site.site_group_id, list);
} else {
ungrouped.push(site);
}
}
return { groupedSites: grouped, ungroupedSites: ungrouped };
}, [sites]);
const isLoading = sitesLoading || groupsLoading;
return (
<div>
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Sites</h1>
<p className="mt-1 text-sm text-text-secondary">
Manage your consent-enabled websites
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowCreateGroup(true)}>
New group
</Button>
<Button onClick={() => setShowCreate(true)}>
Add site
</Button>
</div>
</div>
{isLoading && <LoadingState />}
{sitesError && (
<Alert variant="error">
Failed to load sites. Please try again.
</Alert>
)}
{!isLoading && sites && sites.length === 0 && (!groups || groups.length === 0) && (
<EmptyState message="No sites yet. Add your first site to get started." />
)}
{!isLoading && (
<div className="space-y-6">
{/* Render each group */}
{groups?.map((group) => (
<GroupSection
key={group.id}
group={group}
sites={groupedSites.get(group.id) ?? []}
allGroups={groups}
groupId={group.id}
onDelete={() => {
if (confirm(`Delete group "${group.name}"? Sites will become ungrouped.`)) {
deleteGroupMutation.mutate(group.id);
}
}}
onRemoveSite={(siteId) =>
assignGroupMutation.mutate({ siteId, groupId: null })
}
onMoveSite={(siteId, groupId) =>
assignGroupMutation.mutate({ siteId, groupId })
}
/>
))}
{/* Ungrouped sites */}
{ungroupedSites.length > 0 && (
<div>
{groups && groups.length > 0 && (
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-text-secondary">
Ungrouped sites
</h2>
)}
<SiteTable
sites={ungroupedSites}
groups={groups ?? []}
onAssignGroup={(siteId, groupId) =>
assignGroupMutation.mutate({ siteId, groupId })
}
/>
</div>
)}
</div>
)}
{showCreate && <CreateSiteModal onClose={() => setShowCreate(false)} />}
<Modal
open={showCreateGroup}
onClose={() => {
setShowCreateGroup(false);
setNewGroupName('');
setCreateGroupError('');
}}
title="Create site group"
>
<form
onSubmit={(e) => {
e.preventDefault();
createGroupMutation.mutate({ name: newGroupName });
}}
className="space-y-4"
>
{createGroupError && <Alert variant="error">{createGroupError}</Alert>}
<FormField label="Group name">
<Input
id="group-name"
type="text"
required
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="e.g. Steve Madden"
/>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setShowCreateGroup(false);
setNewGroupName('');
setCreateGroupError('');
}}
>
Cancel
</Button>
<Button type="submit" disabled={createGroupMutation.isPending}>
{createGroupMutation.isPending ? 'Creating...' : 'Create group'}
</Button>
</div>
</form>
</Modal>
</div>
);
}
/* ── Group section component ───────────────────────────────────────── */
function GroupSection({
group,
sites,
allGroups,
groupId,
onDelete,
onRemoveSite,
onMoveSite,
}: {
group: SiteGroup;
sites: Site[];
allGroups: SiteGroup[];
groupId: string;
onDelete: () => void;
onRemoveSite: (siteId: string) => void;
onMoveSite: (siteId: string, groupId: string) => void;
}) {
return (
<div>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="font-heading text-sm font-semibold text-foreground">{group.name}</h2>
<Badge variant="neutral">
{sites.length} {sites.length === 1 ? 'site' : 'sites'}
</Badge>
</div>
<div className="flex items-center gap-3">
<Link
to={`/groups/${groupId}`}
className="text-xs font-medium text-copper hover:text-copper/80"
>
Group defaults
</Link>
<button
onClick={onDelete}
className="text-xs text-status-error-fg hover:text-status-error-fg/80"
>
Delete group
</button>
</div>
</div>
{sites.length > 0 ? (
<SiteTable
sites={sites}
groups={allGroups}
currentGroupId={group.id}
onRemoveFromGroup={onRemoveSite}
onAssignGroup={onMoveSite}
/>
) : (
<EmptyState message="No sites in this group yet" />
)}
</div>
);
}
/* ── Shared site table component ───────────────────────────────────── */
function SiteTable({
sites,
groups,
currentGroupId,
onRemoveFromGroup,
onAssignGroup,
}: {
sites: Site[];
groups: SiteGroup[];
currentGroupId?: string;
onRemoveFromGroup?: (siteId: string) => void;
onAssignGroup?: (siteId: string, groupId: string) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-surface text-left text-xs font-medium uppercase tracking-wide text-text-secondary">
<th className="px-4 py-3 lg:px-6">Domain</th>
<th className="px-4 py-3 lg:px-6">Name</th>
<th className="px-4 py-3 lg:px-6">Status</th>
<th className="hidden px-4 py-3 md:table-cell lg:px-6">Created</th>
<th className="px-4 py-3 lg:px-6">Group</th>
<th className="px-4 py-3 lg:px-6"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sites.map((site: Site) => (
<tr key={site.id} className="transition hover:bg-mist">
<td className="px-4 py-3 text-sm font-medium text-foreground lg:px-6">
{site.domain}
</td>
<td className="px-4 py-3 text-sm text-text-secondary lg:px-6">
{site.display_name ?? site.name ?? '-'}
</td>
<td className="px-4 py-3 lg:px-6">
<Badge variant={site.is_active ? 'success' : 'neutral'}>
{site.is_active ? 'Active' : 'Inactive'}
</Badge>
</td>
<td className="hidden px-4 py-3 text-sm text-text-secondary md:table-cell lg:px-6">
{new Date(site.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 lg:px-6">
<GroupAssigner
site={site}
groups={groups}
currentGroupId={currentGroupId}
onRemove={onRemoveFromGroup}
onAssign={onAssignGroup}
/>
</td>
<td className="px-4 py-3 text-right lg:px-6">
<Link
to={`/sites/${site.id}`}
className="text-sm font-medium text-copper hover:text-copper/80"
>
Manage
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card layout */}
<div className="divide-y divide-border sm:hidden">
{sites.map((site: Site) => (
<div key={site.id} className="p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{site.domain}</p>
<p className="mt-0.5 text-xs text-text-secondary">
{site.display_name ?? site.name ?? '-'}
</p>
</div>
<Badge variant={site.is_active ? 'success' : 'neutral'} className="ml-2 shrink-0">
{site.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="mt-3 flex items-center justify-between">
<GroupAssigner
site={site}
groups={groups}
currentGroupId={currentGroupId}
onRemove={onRemoveFromGroup}
onAssign={onAssignGroup}
/>
<Link
to={`/sites/${site.id}`}
className="text-sm font-medium text-copper hover:text-copper/80"
>
Manage
</Link>
</div>
</div>
))}
</div>
</div>
);
}
/* ── Group assigner inline component ─────────────────────────────── */
function GroupAssigner({
site,
groups,
currentGroupId,
onRemove,
onAssign,
}: {
site: Site;
groups: SiteGroup[];
currentGroupId?: string;
onRemove?: (siteId: string) => void;
onAssign?: (siteId: string, groupId: string) => void;
}) {
// Available groups to move to (exclude current group)
const otherGroups = groups.filter((g) => g.id !== currentGroupId);
if (currentGroupId && onRemove) {
// Site is in a group — show remove + move options
return (
<select
value=""
onChange={(e) => {
const val = e.target.value;
if (val === '__remove__') {
onRemove(site.id);
} else if (val && onAssign) {
onAssign(site.id, val);
}
}}
className="rounded-md border border-border px-2 py-1 text-xs text-text-secondary outline-none focus:border-copper"
>
<option value="" disabled>
Move...
</option>
<option value="__remove__">Remove from group</option>
{otherGroups.map((g) => (
<option key={g.id} value={g.id}>
Move to {g.name}
</option>
))}
</select>
);
}
if (!currentGroupId && groups.length > 0 && onAssign) {
// Site is ungrouped — show assign options
return (
<select
value=""
onChange={(e) => {
if (e.target.value && onAssign) {
onAssign(site.id, e.target.value);
}
}}
className="rounded-md border border-border px-2 py-1 text-xs text-text-secondary outline-none focus:border-copper"
>
<option value="" disabled>
Add to group...
</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
);
}
return <span className="text-xs text-text-tertiary">&mdash;</span>;
}

View File

@@ -0,0 +1,58 @@
declare global {
interface Window {
dataLayer: Record<string, unknown>[];
}
}
/** Push an event to the GTM dataLayer. */
function pushEvent(event: string, data?: Record<string, unknown>): void {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event, ...data });
}
/** Initialise analytics with user and org context. Called once after auth. */
export function initAnalytics(user: {
id: string;
email: string;
role: string;
organisation_id: string;
full_name: string | null;
}): void {
pushEvent('user_identified', {
user_id: user.id,
user_email: user.email,
user_role: user.role,
org_id: user.organisation_id,
user_name: user.full_name ?? undefined,
});
}
/** Track a page view. */
export function trackPageView(path: string, title?: string): void {
pushEvent('page_view', { page_path: path, page_title: title });
}
/** Track auth events (login, logout). */
export function trackAuthEvent(
action: 'login' | 'logout',
userId?: string,
): void {
pushEvent('auth_event', { auth_action: action, user_id: userId });
}
/** Track config changes (site config saved, org config updated, etc.). */
export function trackConfigChange(
changeType: string,
details?: Record<string, unknown>,
): void {
pushEvent('config_change', { change_type: changeType, ...details });
}
/** Track feature usage (banner preview, compliance check, scan triggered, etc.). */
export function trackFeatureUsage(
feature: string,
action: string,
details?: Record<string, unknown>,
): void {
pushEvent('feature_usage', { feature, feature_action: action, ...details });
}

View File

@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { getMe, login as loginApi } from '../api/auth';
import { initAnalytics, trackAuthEvent } from '../services/analytics';
import type { User } from '../types/api';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: false,
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
const tokens = await loginApi(email, password);
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
const user = await getMe();
set({ user, isAuthenticated: true, isLoading: false });
initAnalytics(user);
trackAuthEvent('login', user.id);
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: () => {
const { user } = useAuthStore.getState();
trackAuthEvent('logout', user?.id);
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
set({ user: null, isAuthenticated: false });
},
loadUser: async () => {
if (!localStorage.getItem('access_token')) {
set({ isAuthenticated: false });
return;
}
set({ isLoading: true });
try {
const user = await getMe();
set({ user, isAuthenticated: true, isLoading: false });
initAnalytics(user);
} catch {
localStorage.removeItem('access_token');
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
}));

View File

@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import App from '../App';
// Mock extension discovery to avoid loading EE modules in tests
vi.mock('../extensions/registry', () => ({
discoverExtensions: vi.fn(() => Promise.resolve()),
getSiteDetailTabs: vi.fn(() => []),
getPages: vi.fn(() => []),
getNavItems: vi.fn(() => []),
}));
// Mock the auth store to control auth state
vi.mock('../stores/auth', () => ({
useAuthStore: vi.fn(() => ({
user: null,
isAuthenticated: false,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
loadUser: vi.fn(),
})),
}));
describe('App', () => {
it('renders the login page when not authenticated', () => {
render(<App />);
// ConsentOS wordmark renders Consent + OS as two spans for two-tone colour
expect(screen.getByText('Consent')).toBeInTheDocument();
expect(screen.getByText('OS')).toBeInTheDocument();
expect(screen.getByText('Sign in to manage your consent platform')).toBeInTheDocument();
});
it('renders email and password fields on login page', () => {
render(<App />);
expect(screen.getByLabelText('Email address')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('renders the sign in button', () => {
render(<App />);
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,207 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import BannerBuilderTab from '../components/BannerBuilderTab';
import type { BannerConfig } from '../types/api';
const mockOnSave = vi.fn(() => Promise.resolve({}));
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderWithProviders(ui: React.ReactElement) {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
const BASE_CONFIG: { banner_config: BannerConfig | null } = {
banner_config: null,
};
const DEFAULT_PROPS = {
configQueryKey: ['sites', 'site-1', 'config'],
config: BASE_CONFIG,
onSave: mockOnSave,
};
describe('BannerBuilderTab', () => {
it('renders the builder with default state', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByTestId('banner-builder')).toBeInTheDocument();
expect(screen.getByText('Display mode')).toBeInTheDocument();
expect(screen.getByText('Theme')).toBeInTheDocument();
expect(screen.getByText('Layout')).toBeInTheDocument();
expect(screen.getByText('Live preview')).toBeInTheDocument();
});
it('renders all display mode buttons', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText('Bottom banner')).toBeInTheDocument();
expect(screen.getByText('Top banner')).toBeInTheDocument();
expect(screen.getByText('Overlay (modal)')).toBeInTheDocument();
expect(screen.getByText('Corner popup')).toBeInTheDocument();
});
it('renders the preview iframe', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
const preview = screen.getByTestId('banner-preview');
const iframe = within(preview).getByTitle('Banner preview');
expect(iframe).toBeInTheDocument();
expect(iframe.tagName).toBe('IFRAME');
});
it('renders viewport toggle buttons', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText('Desktop')).toBeInTheDocument();
expect(screen.getByText('Mobile')).toBeInTheDocument();
});
it('toggles mobile viewport width', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
const mobileBtn = screen.getByText('Mobile');
fireEvent.click(mobileBtn);
const iframe = screen.getByTitle('Banner preview');
expect(iframe).toHaveStyle({ width: '375px' });
});
it('renders layout toggle checkboxes', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText("Show 'Reject all' button")).toBeInTheDocument();
expect(screen.getByText("Show 'Manage preferences'")).toBeInTheDocument();
expect(screen.getByText('Show close button')).toBeInTheDocument();
expect(screen.getByText('Show cookie count')).toBeInTheDocument();
expect(screen.getByText('Show logo')).toBeInTheDocument();
});
it('shows logo URL field when logo toggle is enabled', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
// Logo is off by default — URL field should not be visible
expect(screen.queryByPlaceholderText('https://example.com/logo.svg')).not.toBeInTheDocument();
// Enable logo
const logoCheckbox = screen.getByText('Show logo').closest('label')!.querySelector('input')!;
fireEvent.click(logoCheckbox);
expect(screen.getByPlaceholderText('https://example.com/logo.svg')).toBeInTheDocument();
});
it('renders button style toggle', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText('Default button style')).toBeInTheDocument();
// Multiple "Filled"/"Outline" buttons exist (default + per-button editors)
expect(screen.getAllByText('Filled').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Outline').length).toBeGreaterThanOrEqual(1);
});
it('renders font selector', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText('Font')).toBeInTheDocument();
expect(screen.getByText('System default')).toBeInTheDocument();
});
it('renders save button', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
expect(screen.getByText('Save banner')).toBeInTheDocument();
});
it('loads existing banner config values', () => {
const configWithBanner = {
banner_config: {
displayMode: 'overlay' as const,
primaryColour: '#ff0000',
backgroundColour: '#000000',
textColour: '#ffffff',
buttonStyle: 'outline' as const,
fontFamily: 'Georgia, serif',
borderRadius: 12,
showRejectAll: false,
showManagePreferences: true,
showCloseButton: true,
showLogo: true,
logoUrl: 'https://example.com/logo.png',
showCookieCount: true,
},
};
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} config={configWithBanner} />,
);
// Check that the close button toggle is checked
const closeLabel = screen.getByText('Show close button').closest('label')!;
const closeCheckbox = closeLabel.querySelector('input') as HTMLInputElement;
expect(closeCheckbox.checked).toBe(true);
// Logo URL field should be visible since showLogo is true
expect(screen.getByPlaceholderText('https://example.com/logo.svg')).toBeInTheDocument();
});
it('calls save mutation when save button is clicked', async () => {
mockOnSave.mockClear();
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
const saveBtn = screen.getByText('Save banner');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
banner_config: expect.objectContaining({
primaryColour: '#2563eb',
backgroundColour: '#ffffff',
textColour: '#1a1a2e',
displayMode: 'bottom_banner',
}),
}));
});
});
it('changes display mode when mode button is clicked', () => {
renderWithProviders(
<BannerBuilderTab {...DEFAULT_PROPS} />,
);
const overlayBtn = screen.getByText('Overlay (modal)');
fireEvent.click(overlayBtn);
// Overlay button should now be active (bg-primary)
expect(overlayBtn.className).toContain('bg-primary');
});
});

View File

@@ -0,0 +1,336 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import BannerPreview from '../components/BannerPreview';
import type { BannerConfig } from '../types/api';
const DEFAULT_CONFIG: BannerConfig = {
primaryColour: '#2563eb',
backgroundColour: '#ffffff',
textColour: '#1a1a2e',
buttonStyle: 'filled',
fontFamily: 'system-ui',
borderRadius: 6,
showRejectAll: true,
showManagePreferences: true,
showCloseButton: false,
showLogo: false,
showCookieCount: false,
};
describe('BannerPreview', () => {
it('renders the preview container', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
expect(screen.getByTestId('banner-preview')).toBeInTheDocument();
});
it('renders an iframe with srcdoc', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const iframe = screen.getByTitle('Banner preview') as HTMLIFrameElement;
expect(iframe).toBeInTheDocument();
expect(iframe.getAttribute('srcdoc')).toBeTruthy();
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
});
it('includes banner text in srcdoc', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const iframe = screen.getByTitle('Banner preview') as HTMLIFrameElement;
const srcdoc = iframe.getAttribute('srcdoc')!;
expect(srcdoc).toContain('We use cookies');
expect(srcdoc).toContain('Accept all');
expect(srcdoc).toContain('Reject all');
expect(srcdoc).toContain('Manage preferences');
});
it('applies theme colours to srcdoc', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, primaryColour: '#ff0000', backgroundColour: '#111111' }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('#ff0000');
expect(srcdoc).toContain('#111111');
});
it('hides reject all button when showRejectAll is false', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showRejectAll: false }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).not.toContain('Reject all');
});
it('hides manage preferences when showManagePreferences is false', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showManagePreferences: false }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).not.toContain('Manage preferences');
});
it('shows close button when enabled', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showCloseButton: true }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('cmp-close');
});
it('does not show close button element when disabled', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showCloseButton: false }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
// The CSS class may still exist in styles, but the button element should not be rendered
expect(srcdoc).not.toContain('<button class="cmp-close"');
});
it('shows cookie count when enabled', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showCookieCount: true }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('12 cookies used on this site');
});
it('shows logo when configured', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, showLogo: true, logoUrl: 'https://example.com/logo.svg' }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('cmp-logo');
expect(srcdoc).toContain('https://example.com/logo.svg');
});
it('includes privacy policy link when URL provided', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl="https://example.com/privacy"
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('Privacy Policy');
});
it('uses mobile width when viewport is mobile', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="mobile"
privacyPolicyUrl={null}
/>,
);
const iframe = screen.getByTitle('Banner preview');
expect(iframe).toHaveStyle({ width: '375px' });
});
it('uses full width for desktop viewport', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const iframe = screen.getByTitle('Banner preview');
expect(iframe).toHaveStyle({ width: '100%' });
});
it('applies overlay positioning styles', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="overlay"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('transform: translate(-50%, -50%)');
expect(srcdoc).toContain('cmp-overlay-bg');
});
it('applies corner popup positioning styles', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="corner_popup"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('bottom: 20px');
expect(srcdoc).toContain('right: 20px');
expect(srcdoc).toContain('width: 380px');
});
it('applies top banner positioning', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="top_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('top: 0');
});
it('applies border radius to buttons', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, borderRadius: 12 }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('border-radius: 12px');
});
it('applies outline button style', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, buttonStyle: 'outline' }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('background: transparent');
});
it('applies custom font family', () => {
render(
<BannerPreview
bannerConfig={{ ...DEFAULT_CONFIG, fontFamily: "'Inter', sans-serif" }}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain("'Inter', sans-serif");
});
it('includes category preferences section', () => {
render(
<BannerPreview
bannerConfig={DEFAULT_CONFIG}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('Necessary');
expect(srcdoc).toContain('Functional');
expect(srcdoc).toContain('Analytics');
expect(srcdoc).toContain('Marketing');
expect(srcdoc).toContain('Personalisation');
expect(srcdoc).toContain('Save preferences');
});
it('escapes HTML in logo URL', () => {
render(
<BannerPreview
bannerConfig={{
...DEFAULT_CONFIG,
showLogo: true,
logoUrl: 'https://example.com/logo.svg?a=1&b=2',
}}
displayMode="bottom_banner"
viewport="desktop"
privacyPolicyUrl={null}
/>,
);
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
expect(srcdoc).toContain('&amp;');
expect(srcdoc).not.toContain('?a=1&b=2"');
});
});

View File

@@ -0,0 +1,299 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import ComplianceDashboardPage from '../pages/ComplianceDashboardPage';
import type { ComplianceScoreSummary, ComplianceScoreTrendResponse, Site } from '../types/api';
// ── Mocks ────────────────────────────────────────────────────────────
const mockListSites = vi.fn<() => Promise<Site[]>>();
const mockGetSummary = vi.fn<() => Promise<ComplianceScoreSummary>>();
const mockGetTrend = vi.fn<() => Promise<ComplianceScoreTrendResponse>>();
vi.mock('../api/sites', () => ({
listSites: (...args: unknown[]) => mockListSites(...(args as [])),
}));
vi.mock('../api/compliance-scores', () => ({
getComplianceScoreSummary: (...args: unknown[]) => mockGetSummary(...(args as [])),
getComplianceScoreTrend: (...args: unknown[]) => mockGetTrend(...(args as [])),
}));
// Mock Recharts to avoid canvas rendering issues in jsdom
vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
LineChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="line-chart">{children}</div>
),
Line: () => <div data-testid="line" />,
XAxis: () => <div data-testid="x-axis" />,
YAxis: () => <div data-testid="y-axis" />,
CartesianGrid: () => <div data-testid="cartesian-grid" />,
Tooltip: () => <div data-testid="tooltip" />,
Legend: () => <div data-testid="legend" />,
}));
// ── Helpers ──────────────────────────────────────────────────────────
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderPage() {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ComplianceDashboardPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
const TEST_SITE: Site = {
id: 'site-1',
organisation_id: 'org-1',
domain: 'example.com',
name: 'Example',
display_name: 'Example Site',
is_active: true,
site_group_id: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
const TEST_SUMMARY: ComplianceScoreSummary = {
site_id: 'site-1',
overall_score: 85,
frameworks: [
{
id: 'score-1',
site_id: 'site-1',
framework: 'gdpr',
score: 80,
status: 'partial',
critical_count: 1,
warning_count: 1,
info_count: 0,
issues: [
{
rule_id: 'gdpr_reject_button',
severity: 'critical',
message: 'Reject button not as prominent as accept.',
recommendation: 'Add a clearly visible reject button.',
},
{
rule_id: 'gdpr_privacy_policy',
severity: 'warning',
message: 'Privacy policy link missing.',
recommendation: 'Add a privacy policy URL.',
},
],
scanned_at: '2026-03-10T04:00:00Z',
created_at: '2026-03-10T04:00:00Z',
},
{
id: 'score-2',
site_id: 'site-1',
framework: 'ccpa',
score: 90,
status: 'partial',
critical_count: 0,
warning_count: 2,
info_count: 0,
issues: [],
scanned_at: '2026-03-10T04:00:00Z',
created_at: '2026-03-10T04:00:00Z',
},
],
};
const TEST_TREND: ComplianceScoreTrendResponse = {
site_id: 'site-1',
framework: null,
data_points: [
{ framework: 'gdpr', score: 75, scanned_at: '2026-03-08T04:00:00Z' },
{ framework: 'gdpr', score: 80, scanned_at: '2026-03-09T04:00:00Z' },
{ framework: 'gdpr', score: 80, scanned_at: '2026-03-10T04:00:00Z' },
],
};
// ── Tests ────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
});
describe('ComplianceDashboardPage', () => {
it('shows empty state when no sites exist', async () => {
mockListSites.mockResolvedValue([]);
renderPage();
await waitFor(() => {
expect(screen.getByText('No sites configured. Add a site first.')).toBeInTheDocument();
});
});
it('renders the dashboard heading', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Compliance Dashboard')).toBeInTheDocument();
});
});
it('shows the site selector with the site name', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
expect(screen.getByText('Example Site')).toBeInTheDocument();
});
});
it('shows the overall compliance score', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Overall Compliance')).toBeInTheDocument();
// Score badge shows 85
expect(screen.getByText('85')).toBeInTheDocument();
});
});
it('shows per-framework scores', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getAllByText('GDPR').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('CCPA/CPRA').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('80')).toBeInTheDocument();
expect(screen.getByText('90')).toBeInTheDocument();
});
});
it('shows the trend chart section', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Score Trends')).toBeInTheDocument();
});
});
it('shows date range selector buttons', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('7 days')).toBeInTheDocument();
expect(screen.getByText('30 days')).toBeInTheDocument();
expect(screen.getByText('90 days')).toBeInTheDocument();
expect(screen.getByText('12 months')).toBeInTheDocument();
});
});
it('shows issues table with framework and severity filters', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('All frameworks')).toBeInTheDocument();
expect(screen.getByText('All severities')).toBeInTheDocument();
});
});
it('shows issue details when issue row is present', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Reject button not as prominent as accept.')).toBeInTheDocument();
});
});
it('shows export buttons', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Export JSON')).toBeInTheDocument();
expect(screen.getByText('Export CSV')).toBeInTheDocument();
});
});
it('shows empty compliance data message when no scores exist', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue({
site_id: 'site-1',
overall_score: 100,
frameworks: [],
});
mockGetTrend.mockResolvedValue({ site_id: 'site-1', framework: null, data_points: [] });
renderPage();
await waitFor(() => {
expect(
screen.getByText('No compliance scores recorded yet. Scores are computed daily.'),
).toBeInTheDocument();
});
});
it('expands issue recommendation on click', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('Reject button not as prominent as accept.')).toBeInTheDocument();
});
// Click the row to expand
fireEvent.click(screen.getByText('Reject button not as prominent as accept.'));
await waitFor(() => {
expect(screen.getByText('Recommendation:')).toBeInTheDocument();
expect(screen.getByText('Add a clearly visible reject button.')).toBeInTheDocument();
});
});
it('shows critical/warning counts in overview', async () => {
mockListSites.mockResolvedValue([TEST_SITE]);
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
mockGetTrend.mockResolvedValue(TEST_TREND);
renderPage();
await waitFor(() => {
expect(screen.getByText('1 critical')).toBeInTheDocument();
expect(screen.getByText('1 warning')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,264 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import SiteConfigTab from '../components/SiteConfigTab';
import type { SiteConfig } from '../types/api';
vi.mock('../api/sites', () => ({
updateSiteConfig: vi.fn(() => Promise.resolve({})),
}));
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderWithProviders(ui: React.ReactElement) {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>{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: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
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,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
};
describe('SiteConfigTab', () => {
it('renders consent settings section', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Consent settings')).toBeInTheDocument();
expect(screen.getByText('Blocking mode')).toBeInTheDocument();
expect(screen.getByText('Consent expiry (days)')).toBeInTheDocument();
expect(screen.getByText('Privacy policy URL')).toBeInTheDocument();
});
it('renders standards section with TCF and GCM toggles', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Standards & integrations')).toBeInTheDocument();
expect(screen.getByText('IAB TCF v2.2')).toBeInTheDocument();
expect(screen.getByText('Google Consent Mode v2')).toBeInTheDocument();
});
it('renders GPP section with enable toggle', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(
screen.getByText('IAB Global Privacy Platform (GPP)'),
).toBeInTheDocument();
expect(screen.getByText('Enable GPP')).toBeInTheDocument();
});
it('shows GPP supported sections when GPP is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Supported sections')).toBeInTheDocument();
expect(
screen.getByText('US National Privacy (Section 7)'),
).toBeInTheDocument();
expect(
screen.getByText('US California — CCPA/CPRA (Section 8)'),
).toBeInTheDocument();
});
it('hides GPP supported sections when GPP is disabled', () => {
const config = { ...BASE_CONFIG, gpp_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(screen.queryByText('Supported sections')).not.toBeInTheDocument();
});
it('renders GPC section with detect toggle', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(
screen.getByText('Global Privacy Control (GPC)'),
).toBeInTheDocument();
expect(screen.getByText('Detect GPC signal')).toBeInTheDocument();
});
it('shows GPC jurisdiction list when GPC is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('California (CCPA/CPRA)')).toBeInTheDocument();
expect(screen.getByText('Colorado (CPA)')).toBeInTheDocument();
expect(screen.getByText('Connecticut (CTDPA)')).toBeInTheDocument();
expect(screen.getByText('Texas (TDPSA)')).toBeInTheDocument();
expect(screen.getByText('Montana (MTCDPA)')).toBeInTheDocument();
});
it('hides GPC jurisdictions when GPC is disabled', () => {
const config = { ...BASE_CONFIG, gpc_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(
screen.queryByText('California (CCPA/CPRA)'),
).not.toBeInTheDocument();
expect(screen.queryByText('Honour globally')).not.toBeInTheDocument();
});
it('shows honour globally toggle when GPC is enabled', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Honour globally')).toBeInTheDocument();
});
it('hides jurisdiction list when global honour is enabled', () => {
const config = { ...BASE_CONFIG, gpc_global_honour: true };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
expect(screen.getByText('Honour globally')).toBeInTheDocument();
expect(
screen.queryByText('Jurisdictions where GPC is legally required'),
).not.toBeInTheDocument();
});
it('toggles GPP section checkbox', () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
// usnat should be checked by default
const usnatLabel = screen
.getByText('US National Privacy (Section 7)')
.closest('label')!;
const usnatCheckbox = usnatLabel.querySelector('input') as HTMLInputElement;
expect(usnatCheckbox.checked).toBe(true);
// usca should be unchecked
const uscaLabel = screen
.getByText('US California — CCPA/CPRA (Section 8)')
.closest('label')!;
const uscaCheckbox = uscaLabel.querySelector('input') as HTMLInputElement;
expect(uscaCheckbox.checked).toBe(false);
// Toggle usca on
fireEvent.click(uscaCheckbox);
expect(uscaCheckbox.checked).toBe(true);
});
it('submits GPP/GPC configuration', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpp_enabled: true,
gpp_supported_apis: ['usnat'],
gpc_enabled: true,
gpc_jurisdictions: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
gpc_global_honour: false,
}),
);
});
});
it('nulls gpp_supported_apis when GPP is disabled on submit', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
const config = { ...BASE_CONFIG, gpp_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpp_enabled: false,
gpp_supported_apis: null,
}),
);
});
});
it('nulls gpc_jurisdictions when GPC is disabled on submit', async () => {
const sitesApi = await import('../api/sites');
const spy = vi.mocked(sitesApi.updateSiteConfig);
spy.mockClear();
const config = { ...BASE_CONFIG, gpc_enabled: false };
renderWithProviders(
<SiteConfigTab siteId="site-1" config={config} />,
);
const saveBtn = screen.getByText('Save configuration');
fireEvent.click(saveBtn);
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
'site-1',
expect.objectContaining({
gpc_enabled: false,
gpc_jurisdictions: null,
}),
);
});
});
it('renders save button and shows success message', async () => {
renderWithProviders(
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
);
expect(screen.getByText('Save configuration')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import SiteScannerTab from '../components/SiteScannerTab';
import type { ScanDiff, ScanJob, ScanJobDetail } from '../types/api';
// ── Mocks ────────────────────────────────────────────────────────────
const mockListScans = vi.fn<() => Promise<ScanJob[]>>();
const mockTriggerScan = vi.fn<() => Promise<ScanJob>>();
const mockGetScan = vi.fn<() => Promise<ScanJobDetail>>();
const mockGetScanDiff = vi.fn<() => Promise<ScanDiff>>();
vi.mock('../api/scanner', () => ({
listScans: (...args: unknown[]) => mockListScans(...(args as [])),
triggerScan: (...args: unknown[]) => mockTriggerScan(...(args as [])),
getScan: (...args: unknown[]) => mockGetScan(...(args as [])),
getScanDiff: (...args: unknown[]) => mockGetScanDiff(...(args as [])),
}));
// ── Helpers ──────────────────────────────────────────────────────────
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderTab() {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<SiteScannerTab siteId="site-1" />
</MemoryRouter>
</QueryClientProvider>,
);
}
const TEST_SCAN: ScanJob = {
id: 'scan-1',
site_id: 'site-1',
status: 'completed',
trigger: 'manual',
pages_scanned: 5,
pages_total: 10,
cookies_found: 3,
error_message: null,
started_at: '2026-03-10T10:00:00Z',
completed_at: '2026-03-10T10:05:00Z',
created_at: '2026-03-10T10:00:00Z',
updated_at: '2026-03-10T10:05:00Z',
};
const TEST_SCAN_DETAIL: ScanJobDetail = {
...TEST_SCAN,
results: [
{
id: 'r-1',
scan_job_id: 'scan-1',
page_url: 'https://example.com/',
cookie_name: '_ga',
cookie_domain: '.example.com',
storage_type: 'cookie',
attributes: null,
script_source: 'https://www.googletagmanager.com/gtag/js',
auto_category: 'analytics',
initiator_chain: [
'https://example.com/',
'https://www.googletagmanager.com/gtm.js',
'https://www.google-analytics.com/analytics.js',
],
found_at: '2026-03-10T10:03:00Z',
created_at: '2026-03-10T10:03:00Z',
},
{
id: 'r-2',
scan_job_id: 'scan-1',
page_url: 'https://example.com/',
cookie_name: 'session',
cookie_domain: 'example.com',
storage_type: 'cookie',
attributes: null,
script_source: null,
auto_category: null,
initiator_chain: null,
found_at: '2026-03-10T10:03:00Z',
created_at: '2026-03-10T10:03:00Z',
},
],
};
const TEST_DIFF: ScanDiff = {
current_scan_id: 'scan-1',
previous_scan_id: null,
new_cookies: [],
removed_cookies: [],
changed_cookies: [],
total_new: 0,
total_removed: 0,
total_changed: 0,
};
// ── Tests ────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
});
describe('SiteScannerTab', () => {
it('shows empty state when no scans exist', async () => {
mockListScans.mockResolvedValue([]);
renderTab();
await waitFor(() => {
expect(screen.getByText(/No scans yet/)).toBeInTheDocument();
});
});
it('renders scan history table', async () => {
mockListScans.mockResolvedValue([TEST_SCAN]);
renderTab();
await waitFor(() => {
expect(screen.getByText('completed')).toBeInTheDocument();
expect(screen.getByText('manual')).toBeInTheDocument();
});
});
it('shows View Diff button for completed scans', async () => {
mockListScans.mockResolvedValue([TEST_SCAN]);
renderTab();
await waitFor(() => {
expect(screen.getByText('View Diff')).toBeInTheDocument();
});
});
it('shows initiator chains when expanding a completed scan', async () => {
mockListScans.mockResolvedValue([TEST_SCAN]);
mockGetScanDiff.mockResolvedValue(TEST_DIFF);
mockGetScan.mockResolvedValue(TEST_SCAN_DETAIL);
renderTab();
await waitFor(() => {
expect(screen.getByText('View Diff')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('View Diff'));
await waitFor(() => {
// Should show initiator chains section with 1 cookie (the one with a chain)
expect(screen.getByText('Initiator Chains (1 cookies)')).toBeInTheDocument();
// Should show the cookie name
expect(screen.getByText('_ga')).toBeInTheDocument();
});
});
it('shows no initiator chains message when none detected', async () => {
mockListScans.mockResolvedValue([TEST_SCAN]);
mockGetScanDiff.mockResolvedValue(TEST_DIFF);
mockGetScan.mockResolvedValue({
...TEST_SCAN,
results: [
{
id: 'r-2',
scan_job_id: 'scan-1',
page_url: 'https://example.com/',
cookie_name: 'session',
cookie_domain: 'example.com',
storage_type: 'cookie',
attributes: null,
script_source: null,
auto_category: null,
initiator_chain: null,
found_at: '2026-03-10T10:03:00Z',
created_at: '2026-03-10T10:03:00Z',
},
],
});
renderTab();
await waitFor(() => {
expect(screen.getByText('View Diff')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('View Diff'));
await waitFor(() => {
expect(screen.getByText('No initiator chains detected in this scan.')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
initAnalytics,
trackPageView,
trackAuthEvent,
trackConfigChange,
trackFeatureUsage,
} from '../services/analytics';
describe('analytics service', () => {
beforeEach(() => {
window.dataLayer = [];
});
it('pushes user_identified event on initAnalytics', () => {
initAnalytics({
id: 'u1',
email: 'test@example.com',
role: 'admin',
organisation_id: 'org1',
full_name: 'Test User',
});
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({
event: 'user_identified',
user_id: 'u1',
user_email: 'test@example.com',
user_role: 'admin',
org_id: 'org1',
user_name: 'Test User',
});
});
it('pushes page_view event on trackPageView', () => {
trackPageView('/sites', 'Sites');
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({
event: 'page_view',
page_path: '/sites',
page_title: 'Sites',
});
});
it('pushes auth_event on trackAuthEvent', () => {
trackAuthEvent('login', 'u1');
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({
event: 'auth_event',
auth_action: 'login',
user_id: 'u1',
});
});
it('pushes config_change event on trackConfigChange', () => {
trackConfigChange('site_config', { site_id: 's1' });
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({
event: 'config_change',
change_type: 'site_config',
site_id: 's1',
});
});
it('pushes feature_usage event on trackFeatureUsage', () => {
trackFeatureUsage('scan', 'trigger', { site_id: 's1' });
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({
event: 'feature_usage',
feature: 'scan',
feature_action: 'trigger',
site_id: 's1',
});
});
it('initialises dataLayer if not present', () => {
// @ts-expect-error — testing uninitialised state
delete window.dataLayer;
trackPageView('/test');
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toMatchObject({ event: 'page_view' });
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
// We test the store logic in isolation
describe('auth store', () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it('starts unauthenticated when no token is stored', async () => {
const { useAuthStore } = await import('../stores/auth');
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.user).toBeNull();
});
it('starts authenticated when a token exists in localStorage', async () => {
localStorage.setItem('access_token', 'test-token');
const { useAuthStore } = await import('../stores/auth');
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
});
it('logout clears tokens and resets state', async () => {
localStorage.setItem('access_token', 'test-token');
localStorage.setItem('refresh_token', 'test-refresh');
const { useAuthStore } = await import('../stores/auth');
useAuthStore.getState().logout();
expect(localStorage.getItem('access_token')).toBeNull();
expect(localStorage.getItem('refresh_token')).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().user).toBeNull();
});
});

View File

@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// We need to isolate each test from the module-level singleton state,
// so we re-import the module fresh for each test.
describe('UI extension registry', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let registry: typeof import('../extensions/registry');
beforeEach(async () => {
// Reset module cache to get a clean registry each time
const modulePath = '../extensions/registry';
// Vitest doesn't natively re-import, so we use dynamic import with cache busting
vi.resetModules();
registry = await import(modulePath);
});
describe('getSiteDetailTabs', () => {
it('returns empty array when no tabs registered', () => {
expect(registry.getSiteDetailTabs()).toEqual([]);
});
it('returns registered tabs sorted by order', () => {
const FakeComponent = () => null;
registry.registerSiteDetailTab({
id: 'tab-b',
label: 'Tab B',
component: FakeComponent,
order: 300,
});
registry.registerSiteDetailTab({
id: 'tab-a',
label: 'Tab A',
component: FakeComponent,
order: 200,
});
const tabs = registry.getSiteDetailTabs();
expect(tabs).toHaveLength(2);
expect(tabs[0].id).toBe('tab-a');
expect(tabs[1].id).toBe('tab-b');
});
it('does not register duplicate tab ids', () => {
const FakeComponent = () => null;
registry.registerSiteDetailTab({
id: 'dup',
label: 'First',
component: FakeComponent,
});
registry.registerSiteDetailTab({
id: 'dup',
label: 'Second',
component: FakeComponent,
});
expect(registry.getSiteDetailTabs()).toHaveLength(1);
expect(registry.getSiteDetailTabs()[0].label).toBe('First');
});
});
describe('getPages', () => {
it('returns empty array when no pages registered', () => {
expect(registry.getPages()).toEqual([]);
});
it('returns registered pages', () => {
const FakeComponent = () => null;
registry.registerPage({
path: '/ee/billing',
component: FakeComponent,
});
const pages = registry.getPages();
expect(pages).toHaveLength(1);
expect(pages[0].path).toBe('/ee/billing');
});
it('does not register duplicate paths', () => {
const FakeComponent = () => null;
registry.registerPage({ path: '/ee/billing', component: FakeComponent });
registry.registerPage({ path: '/ee/billing', component: FakeComponent });
expect(registry.getPages()).toHaveLength(1);
});
});
describe('getNavItems', () => {
it('returns empty array when no nav items registered', () => {
expect(registry.getNavItems()).toEqual([]);
});
it('returns registered nav items sorted by order', () => {
registry.registerNavItem({ path: '/ee/b', label: 'B', order: 300 });
registry.registerNavItem({ path: '/ee/a', label: 'A', order: 200 });
const items = registry.getNavItems();
expect(items).toHaveLength(2);
expect(items[0].label).toBe('A');
expect(items[1].label).toBe('B');
});
it('does not register duplicate paths', () => {
registry.registerNavItem({ path: '/ee/a', label: 'A' });
registry.registerNavItem({ path: '/ee/a', label: 'A2' });
expect(registry.getNavItems()).toHaveLength(1);
});
});
describe('discoverExtensions', () => {
it('does not throw and is callable', () => {
// discoverExtensions uses import.meta.glob which is Vite-specific.
// In the test environment the EE module may not fully resolve, so
// we verify the function exists and is callable rather than
// executing the full dynamic import chain.
expect(typeof registry.discoverExtensions).toBe('function');
});
});
});

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { chiSquaredTest, requiredSampleSize } from '../utils/statistics';
describe('chiSquaredTest', () => {
it('returns not_enough_data when total observations below threshold', () => {
const result = chiSquaredTest([
{ successes: 5, total: 20 },
{ successes: 3, total: 15 },
]);
expect(result.level).toBe('not_enough_data');
expect(result.pValue).toBeNull();
});
it('returns not_significant for identical rates', () => {
const result = chiSquaredTest([
{ successes: 50, total: 100 },
{ successes: 50, total: 100 },
]);
expect(result.level).toBe('not_significant');
expect(result.pValue).toBeCloseTo(1, 1);
});
it('returns significant for very different rates with large sample', () => {
// 80% vs 40% with n=500 each — should be extremely significant
const result = chiSquaredTest([
{ successes: 400, total: 500 },
{ successes: 200, total: 500 },
]);
expect(result.level).toBe('significant');
expect(result.confidence).toBeGreaterThan(95);
expect(result.pValue).toBeLessThan(0.05);
});
it('returns trending for moderate differences', () => {
// Find sample sizes that produce ~90-95% confidence
// 55% vs 45% with n=200 each
const result = chiSquaredTest([
{ successes: 110, total: 200 },
{ successes: 90, total: 200 },
]);
// This should be somewhere between not significant and significant
expect(result.pValue).not.toBeNull();
expect(result.confidence).not.toBeNull();
expect(result.confidence!).toBeGreaterThan(0);
});
it('handles three variants', () => {
const result = chiSquaredTest([
{ successes: 80, total: 100 },
{ successes: 60, total: 100 },
{ successes: 40, total: 100 },
]);
expect(result.level).toBe('significant');
});
it('handles zero success rate', () => {
const result = chiSquaredTest([
{ successes: 0, total: 200 },
{ successes: 0, total: 200 },
]);
expect(result.level).toBe('not_significant');
});
it('handles 100% success rate', () => {
const result = chiSquaredTest([
{ successes: 200, total: 200 },
{ successes: 200, total: 200 },
]);
expect(result.level).toBe('not_significant');
});
it('correctly identifies known chi-squared value (manual verification)', () => {
// With 2 groups: 70/100 vs 50/100
// Expected: (120/200)*100 = 60 per group
// Chi-sq = (70-60)^2/60 + (30-40)^2/40 + (50-60)^2/60 + (50-40)^2/40
// = 100/60 + 100/40 + 100/60 + 100/40
// = 1.667 + 2.5 + 1.667 + 2.5 = 8.333
// df=1, p-value ≈ 0.0039 → highly significant
const result = chiSquaredTest([
{ successes: 70, total: 100 },
{ successes: 50, total: 100 },
]);
expect(result.level).toBe('significant');
expect(result.pValue!).toBeLessThan(0.01);
expect(result.confidence!).toBeGreaterThan(99);
});
});
describe('requiredSampleSize', () => {
it('returns a positive number for valid inputs', () => {
const n = requiredSampleSize(0.5, 0.1);
expect(n).toBeGreaterThan(0);
expect(Number.isFinite(n)).toBe(true);
});
it('returns larger sample for smaller detectable effect', () => {
const n5 = requiredSampleSize(0.5, 0.05);
const n10 = requiredSampleSize(0.5, 0.1);
expect(n5).toBeGreaterThan(n10);
});
it('returns Infinity for zero or negative effect', () => {
expect(requiredSampleSize(0.5, 0)).toBe(Infinity);
expect(requiredSampleSize(0.5, -0.1)).toBe(Infinity);
});
it('returns 0 for invalid baseline rates', () => {
expect(requiredSampleSize(0, 0.1)).toBe(0);
expect(requiredSampleSize(1, 0.1)).toBe(0);
});
it('gives reasonable values for typical consent scenarios', () => {
// 50% baseline, 5% MDE, 80% power, 5% significance
const n = requiredSampleSize(0.5, 0.05);
// Expected ≈ 3000-4000 per variant
expect(n).toBeGreaterThan(1000);
expect(n).toBeLessThan(10000);
});
});

View File

@@ -0,0 +1,290 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Button } from "../components/ui/button.tsx";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "../components/ui/card.tsx";
import { Badge } from "../components/ui/badge.tsx";
import { Input } from "../components/ui/input.tsx";
import { Textarea } from "../components/ui/textarea.tsx";
import { Select } from "../components/ui/select.tsx";
import { FormField } from "../components/ui/form-field.tsx";
import { Modal } from "../components/ui/modal.tsx";
import { EmptyState } from "../components/ui/empty-state.tsx";
import { LoadingState } from "../components/ui/loading-state.tsx";
import { Alert } from "../components/ui/alert.tsx";
import { MetricCard } from "../components/ui/metric-card.tsx";
import { TabGroup } from "../components/ui/tab-group.tsx";
describe("Button", () => {
it("renders with text content", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="destructive">Delete</Button>);
const btn = screen.getByRole("button", { name: "Delete" });
expect(btn.className).toContain("bg-destructive");
});
it("applies size classes", () => {
render(<Button size="sm">Small</Button>);
const btn = screen.getByRole("button", { name: "Small" });
expect(btn.className).toContain("h-9");
});
it("forwards onClick handler", () => {
const handler = vi.fn();
render(<Button onClick={handler}>Press</Button>);
fireEvent.click(screen.getByRole("button", { name: "Press" }));
expect(handler).toHaveBeenCalledOnce();
});
it("merges custom className", () => {
render(<Button className="mt-4">Styled</Button>);
const btn = screen.getByRole("button", { name: "Styled" });
expect(btn.className).toContain("mt-4");
});
});
describe("Card", () => {
it("renders card with header, title, content, and footer", () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>Body</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Body")).toBeInTheDocument();
expect(screen.getByText("Footer")).toBeInTheDocument();
});
});
describe("Badge", () => {
it("renders with variant", () => {
render(<Badge variant="success">Active</Badge>);
const badge = screen.getByText("Active");
expect(badge.className).toContain("bg-status-success-bg");
});
it("defaults to neutral variant", () => {
render(<Badge>Default</Badge>);
const badge = screen.getByText("Default");
expect(badge.className).toContain("bg-mist");
});
});
describe("Input", () => {
it("renders an input element", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("forwards type prop", () => {
render(<Input type="email" placeholder="Email" />);
expect(screen.getByPlaceholderText("Email")).toHaveAttribute("type", "email");
});
});
describe("Textarea", () => {
it("renders a textarea element", () => {
render(<Textarea placeholder="Write here" />);
expect(screen.getByPlaceholderText("Write here")).toBeInTheDocument();
});
});
describe("Select", () => {
it("renders a select element with options", () => {
render(
<Select defaultValue="b">
<option value="a">A</option>
<option value="b">B</option>
</Select>,
);
expect(screen.getByRole("combobox")).toHaveValue("b");
});
});
describe("FormField", () => {
it("renders label and children", () => {
render(
<FormField label="Name">
<Input placeholder="Your name" />
</FormField>,
);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Your name")).toBeInTheDocument();
});
it("shows error message when provided", () => {
render(
<FormField label="Email" error="Required field">
<Input />
</FormField>,
);
expect(screen.getByText("Required field")).toBeInTheDocument();
});
it("does not render error paragraph when no error", () => {
const { container } = render(
<FormField label="Name">
<Input />
</FormField>,
);
expect(container.querySelector("p")).toBeNull();
});
});
describe("Modal", () => {
it("renders when open", () => {
render(
<Modal open={true} onClose={() => {}} title="Test Modal">
<p>Modal content</p>
</Modal>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Test Modal")).toBeInTheDocument();
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
it("does not render when closed", () => {
render(
<Modal open={false} onClose={() => {}} title="Hidden">
<p>Hidden content</p>
</Modal>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose on Escape key", () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="Closeable">
<p>Press escape</p>
</Modal>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="Backdrop">
<p>Click outside</p>
</Modal>,
);
// The backdrop is the element with aria-hidden="true"
const backdrop = document.querySelector("[aria-hidden='true']")!;
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledOnce();
});
});
describe("EmptyState", () => {
it("renders the message", () => {
render(<EmptyState message="No items found" />);
expect(screen.getByText("No items found")).toBeInTheDocument();
});
});
describe("LoadingState", () => {
it("renders default message", () => {
render(<LoadingState />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("renders custom message", () => {
render(<LoadingState message="Fetching data..." />);
expect(screen.getByText("Fetching data...")).toBeInTheDocument();
});
});
describe("Alert", () => {
it("renders with error variant", () => {
render(<Alert variant="error">Something went wrong</Alert>);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Something went wrong");
expect(alert.className).toContain("bg-status-error-bg");
});
it("renders with success variant", () => {
render(<Alert variant="success">Saved</Alert>);
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-status-success-bg");
});
});
describe("MetricCard", () => {
it("renders label and value", () => {
render(<MetricCard label="Total cookies" value={142} />);
expect(screen.getByText("Total cookies")).toBeInTheDocument();
expect(screen.getByText("142")).toBeInTheDocument();
});
it("renders comparison when provided", () => {
render(
<MetricCard
label="Consent rate"
value="87.2%"
comparison={{ previous: "82.1%", direction: "up" }}
/>,
);
expect(screen.getByText("87.2%")).toBeInTheDocument();
expect(screen.getByText(/82\.1%/)).toBeInTheDocument();
});
it("shows up arrow for positive direction", () => {
render(
<MetricCard
label="Rate"
value="50%"
comparison={{ previous: "40%", direction: "up" }}
/>,
);
expect(screen.getByText("↑")).toBeInTheDocument();
});
it("shows down arrow for negative direction", () => {
render(
<MetricCard
label="Rate"
value="30%"
comparison={{ previous: "40%", direction: "down" }}
/>,
);
expect(screen.getByText("↓")).toBeInTheDocument();
});
});
describe("TabGroup", () => {
const options = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
];
it("renders all options", () => {
render(<TabGroup options={options} value="day" onChange={() => {}} />);
expect(screen.getByText("Day")).toBeInTheDocument();
expect(screen.getByText("Week")).toBeInTheDocument();
expect(screen.getByText("Month")).toBeInTheDocument();
});
it("calls onChange with correct value on click", () => {
const onChange = vi.fn();
render(<TabGroup options={options} value="day" onChange={onChange} />);
fireEvent.click(screen.getByText("Week"));
expect(onChange).toHaveBeenCalledWith("week");
});
it("highlights the active option", () => {
render(<TabGroup options={options} value="week" onChange={() => {}} />);
const activeBtn = screen.getByText("Week");
expect(activeBtn.className).toContain("bg-card");
});
});

View File

@@ -0,0 +1,675 @@
/** API response types matching the backend Pydantic schemas. */
export interface Organisation {
id: string;
name: string;
slug: string;
created_at: string;
updated_at: string;
}
export interface User {
id: string;
email: string;
full_name: string | null;
role: 'owner' | 'admin' | 'editor' | 'viewer';
is_active: boolean;
organisation_id: string;
created_at: string;
updated_at: string;
}
export interface Site {
id: string;
organisation_id: string;
domain: string;
name: string | null;
display_name: string;
is_active: boolean;
site_group_id: string | null;
created_at: string;
updated_at: string;
}
export interface SiteGroup {
id: string;
organisation_id: string;
name: string;
description: string | null;
site_count: number;
created_at: string;
updated_at: string;
}
export interface SiteGroupConfig {
id: string;
site_group_id: string;
blocking_mode: 'opt_in' | 'opt_out' | 'informational' | null;
regional_modes: Record<string, string> | null;
tcf_enabled: boolean | null;
tcf_publisher_cc: string | null;
gcm_enabled: boolean | null;
gcm_default: Record<string, 'granted' | 'denied'> | null;
shopify_privacy_enabled: boolean | null;
gpp_enabled: boolean | null;
gpp_supported_apis: string[] | null;
gpc_enabled: boolean | null;
gpc_jurisdictions: string[] | null;
gpc_global_honour: boolean | null;
banner_config: BannerConfig | null;
privacy_policy_url: string | null;
terms_url: string | null;
scan_schedule_cron: string | null;
scan_max_pages: number | null;
consent_expiry_days: number | null;
created_at: string;
updated_at: string;
}
export type ConfigSource = 'system' | 'org' | 'group' | 'site';
export interface ConfigFieldInheritance {
resolved_value: unknown;
source: ConfigSource;
site_value: unknown;
group_value: unknown;
org_value: unknown;
system_value: unknown;
}
export interface ConfigInheritanceResponse {
site_id: string;
site_group_id: string | null;
fields: Record<string, ConfigFieldInheritance>;
}
export interface OrgConfig {
id: string;
organisation_id: string;
blocking_mode: 'opt_in' | 'opt_out' | 'informational' | null;
regional_modes: Record<string, string> | null;
tcf_enabled: boolean | null;
tcf_publisher_cc: string | null;
gpp_enabled: boolean | null;
gpp_supported_apis: string[] | null;
gpc_enabled: boolean | null;
gpc_jurisdictions: string[] | null;
gpc_global_honour: boolean | null;
gcm_enabled: boolean | null;
gcm_default: Record<string, 'granted' | 'denied'> | null;
shopify_privacy_enabled: boolean | null;
banner_config: BannerConfig | null;
privacy_policy_url: string | null;
terms_url: string | null;
scan_schedule_cron: string | null;
scan_max_pages: number | null;
consent_expiry_days: number | null;
created_at: string;
updated_at: string;
}
export interface SiteConfig {
id: string;
site_id: string;
blocking_mode: 'opt_in' | 'opt_out' | 'informational';
regional_modes: Record<string, string> | null;
tcf_enabled: boolean;
gpp_enabled: boolean;
gpp_supported_apis: string[] | null;
gpc_enabled: boolean;
gpc_jurisdictions: string[] | null;
gpc_global_honour: boolean;
gcm_enabled: boolean;
gcm_default: Record<string, 'granted' | 'denied'> | null;
shopify_privacy_enabled: boolean;
banner_config: BannerConfig | null;
privacy_policy_url: string | null;
terms_url: string | null;
consent_expiry_days: number;
scan_enabled: boolean;
scan_frequency_hours: number;
scan_max_pages: number;
created_at: string;
updated_at: string;
}
export interface ButtonConfig {
backgroundColour?: string;
textColour?: string;
borderColour?: string;
style?: 'filled' | 'outline' | 'text';
}
export interface BannerTextConfig {
title?: string;
description?: string;
acceptAll?: string;
rejectAll?: string;
managePreferences?: string;
savePreferences?: string;
}
export interface BannerConfig {
displayMode?: 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
cornerPosition?: 'left' | 'right';
primaryColour?: string;
backgroundColour?: string;
textColour?: string;
buttonStyle?: 'filled' | 'outline';
fontFamily?: string;
customFontUrl?: string;
borderRadius?: number;
showLogo?: boolean;
logoUrl?: string;
showRejectAll?: boolean;
showManagePreferences?: boolean;
showCloseButton?: boolean;
showCookieCount?: boolean;
acceptButton?: ButtonConfig;
rejectButton?: ButtonConfig;
manageButton?: ButtonConfig;
text?: BannerTextConfig;
}
export interface Translation {
id: string;
site_id: string;
locale: string;
strings: Record<string, string>;
created_at: string;
updated_at: string;
}
export interface CookieCategory {
id: string;
name: string;
slug: string;
description: string | null;
is_essential: boolean;
display_order: number;
tcf_purpose_ids: number[] | null;
gcm_consent_types: string[] | null;
created_at: string;
updated_at: string;
}
export interface Cookie {
id: string;
site_id: string;
category_id: string | null;
name: string;
domain: string;
storage_type: string;
description: string | null;
vendor: string | null;
review_status: 'pending' | 'approved' | 'rejected';
created_at: string;
updated_at: string;
}
export interface AllowListEntry {
id: string;
site_id: string;
category_id: string;
name_pattern: string;
domain_pattern: string;
description: string | null;
created_at: string;
updated_at: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface ScanJob {
id: string;
site_id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
trigger: 'manual' | 'scheduled' | 'client_report';
pages_scanned: number;
pages_total: number | null;
cookies_found: number;
error_message: string | null;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface ScanResult {
id: string;
scan_job_id: string;
page_url: string;
cookie_name: string;
cookie_domain: string;
storage_type: string;
attributes: Record<string, unknown> | null;
script_source: string | null;
auto_category: string | null;
initiator_chain: string[] | null;
found_at: string;
created_at: string;
}
export interface ScanJobDetail extends ScanJob {
results: ScanResult[];
}
export interface CookieDiffItem {
name: string;
domain: string;
storage_type: string;
diff_status: 'new' | 'removed' | 'changed';
details: string | null;
}
export interface ScanDiff {
current_scan_id: string;
previous_scan_id: string | null;
new_cookies: CookieDiffItem[];
removed_cookies: CookieDiffItem[];
changed_cookies: CookieDiffItem[];
total_new: number;
total_removed: number;
total_changed: number;
}
// ── Cross-domain consent sync ────────────────────────────────────────
export interface ConsentGroup {
id: string;
org_id: string;
name: string;
description: string | null;
merge_strategy: 'server_wins' | 'latest_wins';
created_at: string;
updated_at: string;
}
export interface ConsentGroupSite {
id: string;
domain: string;
display_name: string;
}
export interface PublicKey {
id: string;
org_id: string;
name: string;
algorithm: 'RS256' | 'ES256';
is_active: boolean;
created_at: string;
}
// ── Compliance ──────────────────────────────────────────────────────
export type ComplianceFramework = 'gdpr' | 'cnil' | 'ccpa' | 'eprivacy' | 'lgpd';
export type ComplianceSeverity = 'critical' | 'warning' | 'info';
export type ComplianceStatus = 'compliant' | 'partial' | 'non_compliant';
export interface ComplianceIssue {
rule_id: string;
severity: ComplianceSeverity;
message: string;
recommendation: string;
}
export interface FrameworkResult {
framework: ComplianceFramework;
score: number;
status: ComplianceStatus;
issues: ComplianceIssue[];
rules_checked: number;
rules_passed: number;
}
export interface ComplianceCheckResponse {
site_id: string;
results: FrameworkResult[];
overall_score: number;
}
// ── Analytics ───────────────────────────────────────────────────────
export interface ActionBreakdown {
accept_all: number;
reject_all: number;
custom: number;
withdraw: number;
}
export interface CategoryRate {
category: string;
accepted: number;
rejected: number;
rate: number;
}
export interface ConsentRatesResponse {
site_id: string;
total_records: number;
consent_rate: number;
action_breakdown: ActionBreakdown;
category_rates: CategoryRate[];
from_date: string;
to_date: string;
}
export interface TrendPoint {
period: string;
total: number;
accept_all: number;
reject_all: number;
custom: number;
consent_rate: number;
}
export interface ConsentTrendsResponse {
site_id: string;
granularity: 'day' | 'week' | 'month';
data: TrendPoint[];
from_date: string;
to_date: string;
}
export interface RegionMetric {
country_code: string;
region_code: string | null;
total: number;
accept_all: number;
reject_all: number;
custom: number;
consent_rate: number;
}
export interface RegionalBreakdownResponse {
site_id: string;
regions: RegionMetric[];
from_date: string;
to_date: string;
}
export interface AnalyticsSummaryResponse {
site_id: string;
total_records: number;
consent_rate: number;
accept_all_rate: number;
reject_all_rate: number;
custom_rate: number;
top_countries: RegionMetric[];
from_date: string;
to_date: string;
}
// ── A/B Testing ─────────────────────────────────────────────────────
export type ABTestStatus = 'draft' | 'running' | 'paused' | 'completed';
export interface ABTestVariant {
id: string;
ab_test_id: string;
name: string;
traffic_percentage: number;
banner_config_override: Partial<BannerConfig> | null;
is_control: boolean;
created_at: string;
updated_at: string;
}
export interface ABTest {
id: string;
site_id: string;
created_by: string | null;
name: string;
description: string | null;
status: ABTestStatus;
start_date: string | null;
end_date: string | null;
variants: ABTestVariant[];
created_at: string;
updated_at: string;
}
export interface ABTestVariantCreate {
name: string;
traffic_percentage: number;
banner_config_override?: Partial<BannerConfig> | null;
is_control: boolean;
}
export interface ABTestCreate {
name: string;
description?: string | null;
start_date?: string | null;
end_date?: string | null;
variants: ABTestVariantCreate[];
}
// ── Preference Centre ──────────────────────────────────────────────
export type PreferenceCategory = 'cookie_consent' | 'communication' | 'data_sharing';
export interface PreferenceType {
id: string;
site_id: string;
name: string;
slug: string;
description: string | null;
category: PreferenceCategory;
is_active: boolean;
display_order: number;
created_at: string;
updated_at: string;
}
export interface PreferenceTypeCreate {
name: string;
slug: string;
description?: string | null;
category: PreferenceCategory;
is_active?: boolean;
display_order?: number;
}
export interface PreferenceTypeUpdate {
name?: string;
description?: string | null;
category?: PreferenceCategory;
is_active?: boolean;
display_order?: number;
}
export interface UserPreferenceRecord {
id: string;
site_id: string;
user_identifier_hash: string;
preference_type_id: string;
value: 'granted' | 'denied';
source: 'banner' | 'preference_centre' | 'api';
created_at: string;
updated_at: string;
}
export interface PreferenceCentreConfig {
site_id: string;
site_name: string;
preference_types: PreferenceType[];
current_preferences: UserPreferenceRecord[];
}
export interface PreferenceHistoryEntry {
preference_type_name: string;
preference_type_slug: string;
value: string;
source: string;
created_at: string;
}
export interface PreferenceHistoryResponse {
site_id: string;
user_identifier_hash: string;
entries: PreferenceHistoryEntry[];
}
// ── Policy Documents ──────────────────────────────────────────────
export interface PolicyDocument {
id: string;
site_id: string;
type: 'cookie_policy' | 'privacy_section';
content_html: string | null;
content_markdown: string | null;
template_overrides: PolicyTemplateOverrides | null;
generated_at: string | null;
published_at: string | null;
created_at: string;
updated_at: string;
}
export interface PolicyTemplateOverrides {
introduction_text?: string | null;
additional_sections?: { title: string; content: string }[] | null;
language?: string | null;
}
// ── Compliance Scores (server-side monitoring) ─────────────────────
export interface ComplianceScoreRecord {
id: string;
site_id: string;
framework: string;
score: number;
status: ComplianceStatus;
critical_count: number;
warning_count: number;
info_count: number;
issues: unknown;
scanned_at: string;
created_at: string;
}
export interface ComplianceScoreSummary {
site_id: string;
overall_score: number;
frameworks: ComplianceScoreRecord[];
}
export interface ComplianceScoreTrendPoint {
framework: string;
score: number;
scanned_at: string;
}
export interface ComplianceScoreTrendResponse {
site_id: string;
framework: string | null;
data_points: ComplianceScoreTrendPoint[];
}
export interface ValidationIssueResponse {
check: string;
severity: string;
message: string;
recommendation: string;
details: Record<string, unknown>;
}
export interface ValidationResultResponse {
url: string;
pre_consent_issues: ValidationIssueResponse[];
post_accept_issues: ValidationIssueResponse[];
post_reject_issues: ValidationIssueResponse[];
dark_pattern_issues: ValidationIssueResponse[];
banner_found: boolean;
errors: string[];
}
export interface ABTestComplianceResult {
variant_id: string;
variant_name: string;
compliant: boolean;
issues: {
framework: string;
severity: string;
rule_id: string;
message: string;
recommendation: string;
}[];
}
// ── DSAR & Retention ────────────────────────────────────────────────
export type DsarIdentifierType = 'email' | 'consent_id' | 'visitor_id';
export type DsarRequestType = 'access' | 'deletion';
export type DsarStatus = 'pending' | 'processing' | 'completed' | 'rejected';
export interface DsarRequestResponse {
id: string;
org_id: string;
site_id: string | null;
requester_identifier: string;
requester_identifier_type: DsarIdentifierType;
request_type: DsarRequestType;
status: DsarStatus;
submitted_at: string;
processed_at: string | null;
processed_by: string | null;
notes: string | null;
result_data: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface RetentionAuditLogResponse {
id: string;
site_id: string;
records_anonymised: number;
records_deleted: number;
retention_days: number;
purge_date: string;
created_at: string;
updated_at: string;
}
// ── Consent Receipts ────────────────────────────────────────────────
export interface ConsentReceiptResponse {
id: string;
consent_record_id: string;
site_id: string;
receipt_data: {
receipt_id: string;
version: string;
timestamp: string;
jurisdiction: { country_code: string | null; region_code: string | null };
site: { id: string; domain: string | null; name: string | null };
page_url: string | null;
banner_version_hash: string;
banner_content: {
banner_config: Record<string, unknown> | null;
translation_strings: Record<string, string> | null;
};
consent: {
action: string;
categories_accepted: string[];
categories_rejected: string[];
};
signals: {
tc_string: string | null;
gpp_string: string | null;
gcm_state: Record<string, string> | null;
gpc_detected: boolean | null;
gpc_honoured: boolean | null;
};
visitor: {
visitor_id: string;
ip_hash: string | null;
user_agent_hash: string | null;
};
};
banner_version_hash: string | null;
created_at: string;
}

View File

@@ -0,0 +1,256 @@
/**
* Statistical significance utilities for A/B test analysis.
*
* Uses the chi-squared test to determine whether observed differences
* in consent rates between variants are statistically significant.
*/
/**
* Chi-squared cumulative distribution function approximation.
*
* Uses the regularised incomplete gamma function for 1 degree of freedom
* (2-variant comparison). Returns P(X <= x) for chi-squared distribution.
*/
function chiSquaredCDF(x: number, df: number): number {
if (x <= 0) return 0;
// Use the regularised lower incomplete gamma function
// For integer/half-integer df, this converges quickly
const k = df / 2;
const xHalf = x / 2;
return regularisedGammaP(k, xHalf);
}
/** Regularised lower incomplete gamma function P(a, x) via series expansion. */
function regularisedGammaP(a: number, x: number): number {
if (x < 0) return 0;
if (x === 0) return 0;
// Use series expansion for x < a + 1
if (x < a + 1) {
let sum = 1 / a;
let term = 1 / a;
for (let n = 1; n < 200; n++) {
term *= x / (a + n);
sum += term;
if (Math.abs(term) < 1e-10 * Math.abs(sum)) break;
}
return sum * Math.exp(-x + a * Math.log(x) - lnGamma(a));
}
// Use continued fraction for x >= a + 1
return 1 - regularisedGammaQ(a, x);
}
/** Regularised upper incomplete gamma function Q(a, x) via continued fraction. */
function regularisedGammaQ(a: number, x: number): number {
let c = 1e-30;
let d = 1 / (x + 1 - a);
let h = d;
for (let n = 1; n < 200; n++) {
const an = -n * (n - a);
const bn = x + 2 * n + 1 - a;
d = bn + an * d;
if (Math.abs(d) < 1e-30) d = 1e-30;
c = bn + an / c;
if (Math.abs(c) < 1e-30) c = 1e-30;
d = 1 / d;
const delta = d * c;
h *= delta;
if (Math.abs(delta - 1) < 1e-10) break;
}
return Math.exp(-x + a * Math.log(x) - lnGamma(a)) * h;
}
/** Natural log of the Gamma function using Lanczos approximation. */
function lnGamma(z: number): number {
const g = 7;
const c = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
if (z < 0.5) {
return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z);
}
z -= 1;
let x = c[0];
for (let i = 1; i < g + 2; i++) {
x += c[i] / (z + i);
}
const t = z + g + 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
export type SignificanceLevel = 'not_enough_data' | 'not_significant' | 'trending' | 'significant';
export interface SignificanceResult {
level: SignificanceLevel;
/** Human-readable label */
label: string;
/** p-value (0 to 1), lower = more significant */
pValue: number | null;
/** Confidence percentage (0 to 100) */
confidence: number | null;
}
/**
* Perform a chi-squared test comparing conversion rates between variants.
*
* @param observed Array of { successes, total } per variant
* @param minSampleSize Minimum total observations before testing (default: 100)
*/
export function chiSquaredTest(
observed: { successes: number; total: number }[],
minSampleSize: number = 100,
): SignificanceResult {
const totalObservations = observed.reduce((sum, v) => sum + v.total, 0);
if (totalObservations < minSampleSize) {
return {
level: 'not_enough_data',
label: 'Not enough data',
pValue: null,
confidence: null,
};
}
const totalSuccesses = observed.reduce((sum, v) => sum + v.successes, 0);
const overallRate = totalSuccesses / totalObservations;
if (overallRate === 0 || overallRate === 1) {
return {
level: 'not_significant',
label: 'Not significant',
pValue: 1,
confidence: 0,
};
}
// Calculate chi-squared statistic
let chiSq = 0;
for (const variant of observed) {
const expectedSuccess = variant.total * overallRate;
const expectedFailure = variant.total * (1 - overallRate);
if (expectedSuccess > 0) {
chiSq += Math.pow(variant.successes - expectedSuccess, 2) / expectedSuccess;
}
if (expectedFailure > 0) {
const failures = variant.total - variant.successes;
chiSq += Math.pow(failures - expectedFailure, 2) / expectedFailure;
}
}
const df = observed.length - 1;
const pValue = 1 - chiSquaredCDF(chiSq, df);
const confidence = (1 - pValue) * 100;
if (confidence >= 95) {
return { level: 'significant', label: 'Significant (>95%)', pValue, confidence };
}
if (confidence >= 90) {
return { level: 'trending', label: 'Trending (>90%)', pValue, confidence };
}
return { level: 'not_significant', label: 'Not significant', pValue, confidence };
}
/**
* Calculate the recommended sample size per variant.
*
* Uses the formula for a two-proportion z-test:
* n = (Z_alpha/2 + Z_beta)^2 * (p1(1-p1) + p2(1-p2)) / (p1 - p2)^2
*
* @param baselineRate Current accept-all rate (0-1)
* @param minimumDetectableEffect Minimum relative change to detect (e.g. 0.05 for 5%)
* @param power Statistical power (default: 0.8)
* @param alpha Significance level (default: 0.05)
*/
export function requiredSampleSize(
baselineRate: number,
minimumDetectableEffect: number,
power: number = 0.8,
alpha: number = 0.05,
): number {
if (baselineRate <= 0 || baselineRate >= 1) return 0;
if (minimumDetectableEffect <= 0) return Infinity;
const p1 = baselineRate;
const p2 = baselineRate * (1 + minimumDetectableEffect);
if (p2 >= 1) return Infinity;
const zAlpha = normalQuantile(1 - alpha / 2);
const zBeta = normalQuantile(power);
const numerator = Math.pow(zAlpha + zBeta, 2) * (p1 * (1 - p1) + p2 * (1 - p2));
const denominator = Math.pow(p1 - p2, 2);
return Math.ceil(numerator / denominator);
}
/**
* Approximate inverse normal CDF (quantile function).
*
* Uses the rational approximation from Peter Acklam:
* https://web.archive.org/web/20151030215612/http://home.online.no/~pjacklam/notes/invnorm/
*/
function normalQuantile(p: number): number {
if (p <= 0) return -Infinity;
if (p >= 1) return Infinity;
if (p === 0.5) return 0;
// Coefficients for the rational approximation
const a1 = -3.969683028665376e+01;
const a2 = 2.209460984245205e+02;
const a3 = -2.759285104469687e+02;
const a4 = 1.383577518672690e+02;
const a5 = -3.066479806614716e+01;
const a6 = 2.506628277459239e+00;
const b1 = -5.447609879822406e+01;
const b2 = 1.615858368580409e+02;
const b3 = -1.556989798598866e+02;
const b4 = 6.680131188771972e+01;
const b5 = -1.328068155288572e+01;
const c1 = -7.784894002430293e-03;
const c2 = -3.223964580411365e-01;
const c3 = -2.400758277161838e+00;
const c4 = -2.549732539343734e+00;
const c5 = 4.374664141464968e+00;
const c6 = 2.938163982698783e+00;
const d1 = 7.784695709041462e-03;
const d2 = 3.224671290700398e-01;
const d3 = 2.445134137142996e+00;
const d4 = 3.754408661907416e+00;
const pLow = 0.02425;
const pHigh = 1 - pLow;
let q: number;
let r: number;
if (p < pLow) {
// Rational approximation for lower region
q = Math.sqrt(-2 * Math.log(p));
return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
} else if (p <= pHigh) {
// Rational approximation for central region
q = p - 0.5;
r = q * q;
return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q /
(((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
} else {
// Rational approximation for upper region
q = Math.sqrt(-2 * Math.log(1 - p));
return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
}
}

7
apps/admin-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
/** Virtual module provided by the ee-extensions Vite plugin. */
declare module 'virtual:ee-extensions' {
const mod: unknown;
export default mod;
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@core/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,44 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'
/**
* Vite plugin that provides a no-op ``virtual:ee-extensions`` module.
*
* In the cloud repo this plugin is replaced with one that points to
* the real EE register module. In the OSS repo the virtual module
* simply exports nothing, making ``discoverExtensions()`` a no-op.
*/
function eeExtensions() {
const virtualModuleId = 'virtual:ee-extensions'
const resolvedId = '\0' + virtualModuleId
return {
name: 'ee-extensions',
resolveId(id: string) {
if (id === virtualModuleId) return resolvedId
},
load(id: string) {
if (id === resolvedId) return 'export default undefined;'
},
}
}
export default defineConfig({
plugins: [react(), tailwindcss(), eeExtensions()],
resolve: {
alias: {
'@core': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,28 @@
/// <reference types="vitest/config" />
import path from 'path';
import { defineConfig } from 'vite';
/** No-op virtual module for EE extensions (see vite.config.ts for details). */
function eeExtensions() {
const virtualModuleId = 'virtual:ee-extensions'
const resolvedId = '\0' + virtualModuleId
return {
name: 'ee-extensions',
resolveId(id: string) { if (id === virtualModuleId) return resolvedId },
load(id: string) { if (id === resolvedId) return 'export default undefined;' },
}
}
export default defineConfig({
plugins: [eeExtensions()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
'@core': path.resolve(__dirname, 'src'),
},
},
});

Some files were not shown because too many files have changed in this diff Show More