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:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
**/.git
|
||||||
|
**/__pycache__
|
||||||
|
**/node_modules
|
||||||
|
**/.venv
|
||||||
|
**/*.pyc
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
68
.env.example
Normal file
68
.env.example
Normal 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
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
213
.github/workflows/ci.yml
vendored
Normal 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
48
.gitignore
vendored
Normal 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
23
CHANGELOG.md
Normal 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
291
CLAUDE.md
Normal 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 1–3) | DB schema, FastAPI scaffold, auth, site CRUD, basic banner, consent API, Docker Compose |
|
||||||
|
| 2 (Weeks 4–6) | TCF v2.2, Google Consent Mode v2, script interceptor/auto-blocking, cookie categories, allow-list, config hierarchy, admin UI scaffold |
|
||||||
|
| 3 (Weeks 7–8) | Playwright crawler, auto-categorisation, client-side reporter, scan scheduling, admin UI for scans |
|
||||||
|
| 4 (Weeks 9–10) | Compliance rule engine (GDPR/CNIL/CCPA/ePrivacy/LGPD), consent analytics API, compliance + analytics admin UI |
|
||||||
|
| 5 (Weeks 11–12) | 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
61
CODE_OF_CONDUCT.md
Normal 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
118
CONTRIBUTING.md
Normal 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
96
LICENSE
Normal 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
66
Makefile
Normal 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
182
README.md
Normal 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
34
SECURITY.md
Normal 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).
|
||||||
4
apps/admin-ui/.dockerignore
Normal file
4
apps/admin-ui/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
8
apps/admin-ui/.env.production
Normal file
8
apps/admin-ui/.env.production
Normal 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
24
apps/admin-ui/.gitignore
vendored
Normal 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
11
apps/admin-ui/Dockerfile
Normal 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
73
apps/admin-ui/README.md
Normal 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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
apps/admin-ui/eslint.config.js
Normal file
23
apps/admin-ui/eslint.config.js
Normal 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
43
apps/admin-ui/index.html
Normal 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
29
apps/admin-ui/nginx.conf
Normal 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
5948
apps/admin-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
apps/admin-ui/package.json
Normal file
57
apps/admin-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/admin-ui/public/_headers
Normal file
26
apps/admin-ui/public/_headers
Normal 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
|
||||||
5
apps/admin-ui/public/_redirects
Normal file
5
apps/admin-ui/public/_redirects
Normal 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
|
||||||
5
apps/admin-ui/public/favicon.svg
Normal file
5
apps/admin-ui/public/favicon.svg
Normal 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 |
7
apps/admin-ui/public/logo-lockup.svg
Normal file
7
apps/admin-ui/public/logo-lockup.svg
Normal 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 |
5
apps/admin-ui/public/logo-mark.svg
Normal file
5
apps/admin-ui/public/logo-mark.svg
Normal 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 |
19
apps/admin-ui/scripts/copy-banner.sh
Executable file
19
apps/admin-ui/scripts/copy-banner.sh
Executable 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
90
apps/admin-ui/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/admin-ui/src/api/auth.ts
Normal file
19
apps/admin-ui/src/api/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
121
apps/admin-ui/src/api/client.ts
Normal file
121
apps/admin-ui/src/api/client.ts
Normal 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;
|
||||||
37
apps/admin-ui/src/api/compliance-scores.ts
Normal file
37
apps/admin-ui/src/api/compliance-scores.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/admin-ui/src/api/compliance.ts
Normal file
18
apps/admin-ui/src/api/compliance.ts
Normal 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;
|
||||||
|
}
|
||||||
48
apps/admin-ui/src/api/cookies.ts
Normal file
48
apps/admin-ui/src/api/cookies.ts
Normal 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}`);
|
||||||
|
}
|
||||||
12
apps/admin-ui/src/api/org-config.ts
Normal file
12
apps/admin-ui/src/api/org-config.ts
Normal 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;
|
||||||
|
}
|
||||||
31
apps/admin-ui/src/api/scanner.ts
Normal file
31
apps/admin-ui/src/api/scanner.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/admin-ui/src/api/site-group-config.ts
Normal file
18
apps/admin-ui/src/api/site-group-config.ts
Normal 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;
|
||||||
|
}
|
||||||
32
apps/admin-ui/src/api/site-groups.ts
Normal file
32
apps/admin-ui/src/api/site-groups.ts
Normal 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}`);
|
||||||
|
}
|
||||||
50
apps/admin-ui/src/api/sites.ts
Normal file
50
apps/admin-ui/src/api/sites.ts
Normal 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;
|
||||||
|
}
|
||||||
36
apps/admin-ui/src/api/translations.ts
Normal file
36
apps/admin-ui/src/api/translations.ts
Normal 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}`);
|
||||||
|
}
|
||||||
476
apps/admin-ui/src/components/BannerBuilderTab.tsx
Normal file
476
apps/admin-ui/src/components/BannerBuilderTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
574
apps/admin-ui/src/components/BannerPreview.tsx
Normal file
574
apps/admin-ui/src/components/BannerPreview.tsx
Normal 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">×</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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
107
apps/admin-ui/src/components/CreateSiteModal.tsx
Normal file
107
apps/admin-ui/src/components/CreateSiteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/admin-ui/src/components/ErrorBoundary.tsx
Normal file
114
apps/admin-ui/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/admin-ui/src/components/Layout.tsx
Normal file
141
apps/admin-ui/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/admin-ui/src/components/ProtectedRoute.tsx
Normal file
13
apps/admin-ui/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
477
apps/admin-ui/src/components/SiteComplianceTab.tsx
Normal file
477
apps/admin-ui/src/components/SiteComplianceTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
467
apps/admin-ui/src/components/SiteConfigTab.tsx
Normal file
467
apps/admin-ui/src/components/SiteConfigTab.tsx
Normal 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 “Reset” 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 & 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/admin-ui/src/components/SiteCookiesTab.tsx
Normal file
150
apps/admin-ui/src/components/SiteCookiesTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/admin-ui/src/components/SiteOverviewTab.tsx
Normal file
75
apps/admin-ui/src/components/SiteOverviewTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
apps/admin-ui/src/components/SiteScannerTab.tsx
Normal file
289
apps/admin-ui/src/components/SiteScannerTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
apps/admin-ui/src/components/SiteTranslationsTab.tsx
Normal file
397
apps/admin-ui/src/components/SiteTranslationsTab.tsx
Normal 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();
|
||||||
|
}
|
||||||
35
apps/admin-ui/src/components/ui/alert.tsx
Normal file
35
apps/admin-ui/src/components/ui/alert.tsx
Normal 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 };
|
||||||
38
apps/admin-ui/src/components/ui/badge.tsx
Normal file
38
apps/admin-ui/src/components/ui/badge.tsx
Normal 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 };
|
||||||
49
apps/admin-ui/src/components/ui/button.tsx
Normal file
49
apps/admin-ui/src/components/ui/button.tsx
Normal 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 };
|
||||||
55
apps/admin-ui/src/components/ui/card.tsx
Normal file
55
apps/admin-ui/src/components/ui/card.tsx
Normal 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 };
|
||||||
22
apps/admin-ui/src/components/ui/empty-state.tsx
Normal file
22
apps/admin-ui/src/components/ui/empty-state.tsx
Normal 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 };
|
||||||
23
apps/admin-ui/src/components/ui/form-field.tsx
Normal file
23
apps/admin-ui/src/components/ui/form-field.tsx
Normal 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 };
|
||||||
19
apps/admin-ui/src/components/ui/input.tsx
Normal file
19
apps/admin-ui/src/components/ui/input.tsx
Normal 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 };
|
||||||
20
apps/admin-ui/src/components/ui/loading-state.tsx
Normal file
20
apps/admin-ui/src/components/ui/loading-state.tsx
Normal 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 };
|
||||||
46
apps/admin-ui/src/components/ui/metric-card.tsx
Normal file
46
apps/admin-ui/src/components/ui/metric-card.tsx
Normal 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 };
|
||||||
50
apps/admin-ui/src/components/ui/modal.tsx
Normal file
50
apps/admin-ui/src/components/ui/modal.tsx
Normal 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 };
|
||||||
19
apps/admin-ui/src/components/ui/select.tsx
Normal file
19
apps/admin-ui/src/components/ui/select.tsx
Normal 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 };
|
||||||
43
apps/admin-ui/src/components/ui/tab-group.tsx
Normal file
43
apps/admin-ui/src/components/ui/tab-group.tsx
Normal 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 };
|
||||||
19
apps/admin-ui/src/components/ui/textarea.tsx
Normal file
19
apps/admin-ui/src/components/ui/textarea.tsx
Normal 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 };
|
||||||
137
apps/admin-ui/src/extensions/registry.ts
Normal file
137
apps/admin-ui/src/extensions/registry.ts
Normal 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 0–100. */
|
||||||
|
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 0–100. */
|
||||||
|
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
124
apps/admin-ui/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
6
apps/admin-ui/src/lib/utils.ts
Normal file
6
apps/admin-ui/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
13
apps/admin-ui/src/main.tsx
Normal file
13
apps/admin-ui/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
628
apps/admin-ui/src/pages/ComplianceDashboardPage.tsx
Normal file
628
apps/admin-ui/src/pages/ComplianceDashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/admin-ui/src/pages/LoginPage.tsx
Normal file
84
apps/admin-ui/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
apps/admin-ui/src/pages/SettingsPage.tsx
Normal file
386
apps/admin-ui/src/pages/SettingsPage.tsx
Normal 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 & 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/admin-ui/src/pages/SiteDetailPage.tsx
Normal file
119
apps/admin-ui/src/pages/SiteDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
apps/admin-ui/src/pages/SiteGroupDetailPage.tsx
Normal file
420
apps/admin-ui/src/pages/SiteGroupDetailPage.tsx
Normal 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 · {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 → Organisation defaults
|
||||||
|
→ <span className="font-semibold">Group defaults (this page)</span> →
|
||||||
|
Site-level config → 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 & 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
447
apps/admin-ui/src/pages/SitesPage.tsx
Normal file
447
apps/admin-ui/src/pages/SitesPage.tsx
Normal 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">—</span>;
|
||||||
|
}
|
||||||
58
apps/admin-ui/src/services/analytics.ts
Normal file
58
apps/admin-ui/src/services/analytics.ts
Normal 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 });
|
||||||
|
}
|
||||||
60
apps/admin-ui/src/stores/auth.ts
Normal file
60
apps/admin-ui/src/stores/auth.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
45
apps/admin-ui/src/test/App.test.tsx
Normal file
45
apps/admin-ui/src/test/App.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
207
apps/admin-ui/src/test/BannerBuilderTab.test.tsx
Normal file
207
apps/admin-ui/src/test/BannerBuilderTab.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
336
apps/admin-ui/src/test/BannerPreview.test.tsx
Normal file
336
apps/admin-ui/src/test/BannerPreview.test.tsx
Normal 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('&');
|
||||||
|
expect(srcdoc).not.toContain('?a=1&b=2"');
|
||||||
|
});
|
||||||
|
});
|
||||||
299
apps/admin-ui/src/test/ComplianceDashboardPage.test.tsx
Normal file
299
apps/admin-ui/src/test/ComplianceDashboardPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
264
apps/admin-ui/src/test/SiteConfigTab.test.tsx
Normal file
264
apps/admin-ui/src/test/SiteConfigTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
193
apps/admin-ui/src/test/SiteScannerTab.test.tsx
Normal file
193
apps/admin-ui/src/test/SiteScannerTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/admin-ui/src/test/analytics.test.ts
Normal file
90
apps/admin-ui/src/test/analytics.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/admin-ui/src/test/auth-store.test.ts
Normal file
36
apps/admin-ui/src/test/auth-store.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
120
apps/admin-ui/src/test/extensions-registry.test.ts
Normal file
120
apps/admin-ui/src/test/extensions-registry.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/admin-ui/src/test/setup.ts
Normal file
1
apps/admin-ui/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
119
apps/admin-ui/src/test/statistics.test.ts
Normal file
119
apps/admin-ui/src/test/statistics.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
290
apps/admin-ui/src/test/ui-components.test.tsx
Normal file
290
apps/admin-ui/src/test/ui-components.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
675
apps/admin-ui/src/types/api.ts
Normal file
675
apps/admin-ui/src/types/api.ts
Normal 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;
|
||||||
|
}
|
||||||
256
apps/admin-ui/src/utils/statistics.ts
Normal file
256
apps/admin-ui/src/utils/statistics.ts
Normal 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
7
apps/admin-ui/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
34
apps/admin-ui/tsconfig.app.json
Normal file
34
apps/admin-ui/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
apps/admin-ui/tsconfig.json
Normal file
7
apps/admin-ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
apps/admin-ui/tsconfig.node.json
Normal file
26
apps/admin-ui/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
44
apps/admin-ui/vite.config.ts
Normal file
44
apps/admin-ui/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
28
apps/admin-ui/vitest.config.ts
Normal file
28
apps/admin-ui/vitest.config.ts
Normal 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
Reference in New Issue
Block a user