feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

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

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 335 B

View File

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

After

Width:  |  Height:  |  Size: 645 B

View File

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

After

Width:  |  Height:  |  Size: 335 B

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
apps/api/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
*.egg-info/
tests/
fly.toml
.env
.env.*

51
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# ── Build stage ──────────────────────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir --prefix=/install .
# ── Runtime stage ────────────────────────────────────────────────────
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for security
RUN groupadd -r cmp && useradd -r -g cmp -d /app -s /sbin/nologin cmp
WORKDIR /app
# Copy installed dependencies from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY . .
RUN chown -R cmp:cmp /app
USER cmp
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Start the server. Database migrations and the initial-admin
# bootstrap are owned by a separate init container (see the OSS
# docker-compose in consentos-deployment) — the API assumes the
# schema is ready by the time it starts.
# Workers configurable via WEB_CONCURRENCY (default 4, use 1 for 256MB RAM)
CMD ["sh", "-c", "uvicorn src.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers ${WEB_CONCURRENCY:-4} \
--access-log \
--proxy-headers \
--forwarded-allow-ips '*'"]

149
apps/api/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://consentos:consentos@localhost:5432/consentos
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
apps/api/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

61
apps/api/alembic/env.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from src.models import Base
# Alembic Config object
config = context.config
# Override sqlalchemy.url from environment if set
database_url = os.environ.get("DATABASE_URL")
if database_url:
# Alembic needs the synchronous driver
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", database_url)
# Set up Python logging from the config file
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,442 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-04-13
Creates the full core schema plus seeds the default cookie categories.
"""
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0001'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cookie_categories',
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('slug', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_essential', sa.Boolean(), nullable=False),
sa.Column('display_order', sa.Integer(), server_default='0', nullable=False),
sa.Column('tcf_purpose_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gcm_consent_types', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
sa.UniqueConstraint('slug')
)
op.create_table('organisations',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('contact_email', sa.String(length=255), nullable=True),
sa.Column('billing_plan', sa.String(length=50), server_default='free', nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_organisations_slug'), 'organisations', ['slug'], unique=True)
op.create_table('known_cookies',
sa.Column('name_pattern', sa.String(length=255), nullable=False),
sa.Column('domain_pattern', sa.String(length=255), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.Column('vendor', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_regex', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name_pattern', 'domain_pattern', name='uq_known_cookies_name_domain')
)
op.create_index(op.f('ix_known_cookies_name_pattern'), 'known_cookies', ['name_pattern'], unique=False)
op.create_table('org_configs',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), nullable=True),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=True),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=True),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=True),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=True),
sa.Column('gcm_enabled', sa.Boolean(), nullable=True),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=True),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), nullable=True),
sa.Column('consent_expiry_days', sa.Integer(), nullable=True),
sa.Column('consent_retention_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id')
)
op.create_table('site_groups',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id', 'name', name='uq_site_groups_org_name')
)
op.create_index(op.f('ix_site_groups_organisation_id'), 'site_groups', ['organisation_id'], unique=False)
op.create_table('users',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('role', sa.String(length=20), server_default='viewer', nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_organisation_id'), 'users', ['organisation_id'], unique=False)
op.create_table('site_group_configs',
sa.Column('site_group_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), nullable=True),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=True),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=True),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=True),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=True),
sa.Column('gcm_enabled', sa.Boolean(), nullable=True),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=True),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), nullable=True),
sa.Column('consent_expiry_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_group_id'], ['site_groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_group_id')
)
op.create_table('sites',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('additional_domains', postgresql.ARRAY(sa.String(length=255)), nullable=True),
sa.Column('site_group_id', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['site_group_id'], ['site_groups.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id', 'domain', name='uq_sites_org_domain')
)
op.create_index(op.f('ix_sites_domain'), 'sites', ['domain'], unique=False)
op.create_index(op.f('ix_sites_organisation_id'), 'sites', ['organisation_id'], unique=False)
op.create_index(op.f('ix_sites_site_group_id'), 'sites', ['site_group_id'], unique=False)
op.create_table('consent_records',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('visitor_id', sa.String(length=255), nullable=False),
sa.Column('ip_hash', sa.String(length=64), nullable=True),
sa.Column('user_agent_hash', sa.String(length=64), nullable=True),
sa.Column('action', sa.String(length=30), nullable=False),
sa.Column('categories_accepted', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('categories_rejected', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tc_string', sa.Text(), nullable=True),
sa.Column('gcm_state', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpp_string', sa.Text(), nullable=True),
sa.Column('gpc_detected', sa.Boolean(), nullable=True),
sa.Column('gpc_honoured', sa.Boolean(), nullable=True),
sa.Column('ab_test_id', sa.UUID(), nullable=True),
sa.Column('ab_variant_id', sa.UUID(), nullable=True),
sa.Column('page_url', sa.Text(), nullable=True),
sa.Column('country_code', sa.String(length=5), nullable=True),
sa.Column('region_code', sa.String(length=10), nullable=True),
sa.Column('consented_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_consent_records_ab_test_id'), 'consent_records', ['ab_test_id'], unique=False)
op.create_index(op.f('ix_consent_records_consented_at'), 'consent_records', ['consented_at'], unique=False)
op.create_index(op.f('ix_consent_records_site_id'), 'consent_records', ['site_id'], unique=False)
op.create_index(op.f('ix_consent_records_visitor_id'), 'consent_records', ['visitor_id'], unique=False)
op.create_table('cookie_allow_list',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.Column('name_pattern', sa.String(length=255), nullable=False),
sa.Column('domain_pattern', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'name_pattern', 'domain_pattern', name='uq_allow_list_site_name_domain')
)
op.create_index(op.f('ix_cookie_allow_list_site_id'), 'cookie_allow_list', ['site_id'], unique=False)
op.create_table('cookies',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=False),
sa.Column('storage_type', sa.String(length=30), server_default='cookie', nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('vendor', sa.String(length=255), nullable=True),
sa.Column('path', sa.String(length=500), nullable=True),
sa.Column('max_age_seconds', sa.Integer(), nullable=True),
sa.Column('is_http_only', sa.Boolean(), nullable=True),
sa.Column('is_secure', sa.Boolean(), nullable=True),
sa.Column('same_site', sa.String(length=10), nullable=True),
sa.Column('review_status', sa.String(length=20), server_default='pending', nullable=False),
sa.Column('first_seen_at', sa.String(length=50), nullable=True),
sa.Column('last_seen_at', sa.String(length=50), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'name', 'domain', 'storage_type', name='uq_cookies_site_name_domain_type')
)
op.create_index(op.f('ix_cookies_category_id'), 'cookies', ['category_id'], unique=False)
op.create_index(op.f('ix_cookies_name'), 'cookies', ['name'], unique=False)
op.create_index(op.f('ix_cookies_site_id'), 'cookies', ['site_id'], unique=False)
op.create_table('scan_jobs',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(length=20), server_default='pending', nullable=False),
sa.Column('trigger', sa.String(length=20), server_default='manual', nullable=False),
sa.Column('pages_scanned', sa.Integer(), server_default='0', nullable=False),
sa.Column('pages_total', sa.Integer(), nullable=True),
sa.Column('cookies_found', sa.Integer(), server_default='0', nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_jobs_site_id'), 'scan_jobs', ['site_id'], unique=False)
op.create_index(op.f('ix_scan_jobs_status'), 'scan_jobs', ['status'], unique=False)
op.create_table('site_configs',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), server_default='opt_in', nullable=False),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=False),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=False),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=False),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=False),
sa.Column('gcm_enabled', sa.Boolean(), nullable=False),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=False),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('display_mode', sa.String(length=30), server_default='bottom_banner', nullable=False),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), server_default='50', nullable=False),
sa.Column('consent_expiry_days', sa.Integer(), server_default='365', nullable=False),
sa.Column('consent_retention_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id')
)
op.create_table('translations',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('locale', sa.String(length=10), nullable=False),
sa.Column('strings', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'locale', name='uq_translations_site_locale')
)
op.create_index(op.f('ix_translations_site_id'), 'translations', ['site_id'], unique=False)
op.create_table('scan_results',
sa.Column('scan_job_id', sa.UUID(), nullable=False),
sa.Column('page_url', sa.Text(), nullable=False),
sa.Column('cookie_name', sa.String(length=255), nullable=False),
sa.Column('cookie_domain', sa.String(length=255), nullable=False),
sa.Column('storage_type', sa.String(length=30), server_default='cookie', nullable=False),
sa.Column('attributes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('script_source', sa.Text(), nullable=True),
sa.Column('auto_category', sa.String(length=50), nullable=True),
sa.Column('initiator_chain', postgresql.ARRAY(sa.Text()), nullable=True, comment='Ordered script URLs from root initiator to leaf'),
sa.Column('found_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['scan_job_id'], ['scan_jobs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_results_scan_job_id'), 'scan_results', ['scan_job_id'], unique=False)
# ### end Alembic commands ###
# ── Seed default cookie categories ───────────────────────────────
cookie_categories_table = sa.table(
"cookie_categories",
sa.column("id", sa.UUID()),
sa.column("name", sa.String),
sa.column("slug", sa.String),
sa.column("description", sa.Text),
sa.column("is_essential", sa.Boolean),
sa.column("display_order", sa.Integer),
sa.column("tcf_purpose_ids", postgresql.JSONB),
sa.column("gcm_consent_types", postgresql.JSONB),
)
op.bulk_insert(
cookie_categories_table,
[
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000001"),
"name": "Necessary",
"slug": "necessary",
"description": (
"Essential cookies required for the website to function. "
"These cannot be disabled."
),
"is_essential": True,
"display_order": 0,
"tcf_purpose_ids": None,
"gcm_consent_types": ["functionality_storage", "security_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000002"),
"name": "Functional",
"slug": "functional",
"description": (
"Cookies that enable enhanced functionality and personalisation, "
"such as remembering preferences."
),
"is_essential": False,
"display_order": 1,
"tcf_purpose_ids": [1],
"gcm_consent_types": ["functionality_storage", "personalization_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000003"),
"name": "Analytics",
"slug": "analytics",
"description": (
"Cookies used to collect information about how visitors use the website, "
"helping to improve the site."
),
"is_essential": False,
"display_order": 2,
"tcf_purpose_ids": [7, 8, 9],
"gcm_consent_types": ["analytics_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000004"),
"name": "Marketing",
"slug": "marketing",
"description": (
"Cookies used to deliver personalised advertisements and "
"track advertising campaign performance."
),
"is_essential": False,
"display_order": 3,
"tcf_purpose_ids": [2, 3, 4, 5, 6, 10, 11],
"gcm_consent_types": ["ad_storage", "ad_user_data", "ad_personalization"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000005"),
"name": "Personalisation",
"slug": "personalisation",
"description": (
"Cookies that enable content personalisation based on "
"user profiles and browsing behaviour."
),
"is_essential": False,
"display_order": 4,
"tcf_purpose_ids": [3, 4, 6],
"gcm_consent_types": ["personalization_storage"],
},
],
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_scan_results_scan_job_id'), table_name='scan_results')
op.drop_table('scan_results')
op.drop_index(op.f('ix_translations_site_id'), table_name='translations')
op.drop_table('translations')
op.drop_table('site_configs')
op.drop_index(op.f('ix_scan_jobs_status'), table_name='scan_jobs')
op.drop_index(op.f('ix_scan_jobs_site_id'), table_name='scan_jobs')
op.drop_table('scan_jobs')
op.drop_index(op.f('ix_cookies_site_id'), table_name='cookies')
op.drop_index(op.f('ix_cookies_name'), table_name='cookies')
op.drop_index(op.f('ix_cookies_category_id'), table_name='cookies')
op.drop_table('cookies')
op.drop_index(op.f('ix_cookie_allow_list_site_id'), table_name='cookie_allow_list')
op.drop_table('cookie_allow_list')
op.drop_index(op.f('ix_consent_records_visitor_id'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_site_id'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_consented_at'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_ab_test_id'), table_name='consent_records')
op.drop_table('consent_records')
op.drop_index(op.f('ix_sites_site_group_id'), table_name='sites')
op.drop_index(op.f('ix_sites_organisation_id'), table_name='sites')
op.drop_index(op.f('ix_sites_domain'), table_name='sites')
op.drop_table('sites')
op.drop_table('site_group_configs')
op.drop_index(op.f('ix_users_organisation_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_site_groups_organisation_id'), table_name='site_groups')
op.drop_table('site_groups')
op.drop_table('org_configs')
op.drop_index(op.f('ix_known_cookies_name_pattern'), table_name='known_cookies')
op.drop_table('known_cookies')
op.drop_index(op.f('ix_organisations_slug'), table_name='organisations')
op.drop_table('organisations')
op.drop_table('cookie_categories')
# ### end Alembic commands ###

View File

@@ -0,0 +1,36 @@
"""composite index on consent_records(site_id, consented_at)
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-13
The most common analytic query pattern is "consents for site X in date
range" (consent rates, trends, regional breakdowns). The single-column
indexes on ``site_id`` and ``consented_at`` each help a little, but a
composite index is materially faster for the combined filter.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0002"
down_revision: Union[str, Sequence[str], None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_consent_records_site_consented_at",
"consent_records",
["site_id", "consented_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_consent_records_site_consented_at",
table_name="consent_records",
)

View File

@@ -0,0 +1,9 @@
# Open Cookie Database
The file `open-cookie-database.csv` is sourced from the
[Open Cookie Database](https://github.com/jkwakman/Open-Cookie-Database)
by jkwakman, licensed under the Creative Commons Attribution 4.0 International
(CC BY 4.0) licence.
To update the database, download the latest CSV from the repository above and
replace this file, then run `make seed` to reload the data.

File diff suppressed because it is too large Load Diff

65
apps/api/fly.toml Normal file
View File

@@ -0,0 +1,65 @@
# Fly.io configuration for the ConsentOS API
# See https://fly.io/docs/reference/configuration/ for reference.
#
# This app runs three process groups from the same Docker image:
# - app: FastAPI web server (handles HTTP traffic)
# - worker: Celery worker (processes scan jobs and background tasks)
# - beat: Celery beat scheduler (triggers periodic tasks)
app = "consentos-api"
primary_region = "lhr" # London
[build]
dockerfile = "Dockerfile"
[env]
ENVIRONMENT = "production"
LOG_LEVEL = "INFO"
PORT = "8000"
RATE_LIMIT_ENABLED = "true"
RATE_LIMIT_PER_MINUTE = "120"
# ── Migrations run once per deployment, before processes start ──────
[deploy]
release_command = "python -m alembic upgrade head"
# ── Process groups ──────────────────────────────────────────────────
[processes]
app = "sh start.sh"
worker = "celery -A src.celery_app worker --loglevel=info --concurrency=2"
beat = "celery -A src.celery_app beat --loglevel=info"
# ── HTTP service (only the 'app' process serves HTTP) ───────────────
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[http_service.concurrency]
type = "requests"
hard_limit = 250
soft_limit = 200
# ── VM sizing per process ───────────────────────────────────────────
# The app and beat processes are lightweight; the worker needs more
# memory for processing scan results.
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
processes = ["app"]
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
processes = ["worker"]
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
processes = ["beat"]

64
apps/api/pyproject.toml Normal file
View File

@@ -0,0 +1,64 @@
[project]
name = "consentos-api"
version = "0.1.0"
description = "ConsentOS — API service"
license = "Elastic-2.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn[standard]>=0.34,<1",
"sqlalchemy[asyncio]>=2.0,<3",
"asyncpg>=0.30,<1",
"alembic>=1.14,<2",
"pydantic>=2.0,<3",
"pydantic-settings>=2.0,<3",
"python-jose[cryptography]>=3.3,<4",
"bcrypt>=4.0,<5",
"redis>=5.0,<6",
"celery>=5.4,<6",
"httpx>=0.28,<1",
"structlog>=24.0,<25",
"psycopg2-binary>=2.9,<3",
"email-validator>=2.0,<3",
"jinja2>=3.1,<4",
"markupsafe>=2.1,<3",
"reportlab>=4.0,<5",
"geoip2>=4.8,<5",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9",
"pytest-asyncio>=0.24,<1",
"pytest-cov>=6.0,<7",
"httpx>=0.28,<1",
"ruff>=0.9,<1",
"mypy>=1.13,<2",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"]
ignore = ["B008"] # Depends() in FastAPI defaults is idiomatic
[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]

0
apps/api/src/__init__.py Normal file
View File

View File

@@ -0,0 +1,89 @@
"""Celery application and task definitions for the CMP API.
Provides async-compatible scan scheduling via Celery with Redis as the
broker and result backend.
"""
import ssl
from celery import Celery
from celery.schedules import crontab
from src.config.settings import get_settings
settings = get_settings()
# Named `app` by Celery convention — the CLI finds it via -A src.celery_app
app = Celery(
"cmp",
broker=settings.redis_url,
backend=settings.redis_url,
)
# When using rediss:// (TLS) — e.g. Upstash — Celery requires explicit
# SSL certificate verification settings for both broker and backend.
_conf: dict = {
"task_serializer": "json",
"accept_content": ["json"],
"result_serializer": "json",
"timezone": "UTC",
"enable_utc": True,
"task_track_started": True,
"task_acks_late": True,
"worker_prefetch_multiplier": 1,
}
if settings.redis_url.startswith("rediss://"):
_conf["broker_use_ssl"] = {"ssl_cert_reqs": ssl.CERT_NONE}
_conf["redis_backend_use_ssl"] = {"ssl_cert_reqs": ssl.CERT_NONE}
app.conf.update(**_conf)
# ── Beat schedule (periodic tasks) ──────────────────────────────────
app.conf.beat_schedule = {
"check-scheduled-scans": {
"task": "src.tasks.scanner.check_scheduled_scans",
"schedule": crontab(minute="*/15"), # Every 15 minutes
},
"recover-stale-scans": {
"task": "src.tasks.scanner.recover_stale_scans",
"schedule": crontab(minute="*/5"), # Every 5 minutes
},
"purge-expired-consent-records": {
"task": "src.tasks.retention.purge_expired_consent_records",
"schedule": crontab(hour="1", minute="0"), # Daily at 01:00 UTC
},
}
# ── Explicit task imports ───────────────────────────────────────────
# Must be at the bottom to avoid circular imports. These ensure the
# worker process registers all @app.task definitions on startup.
import src.tasks.retention # noqa: E402
import src.tasks.scanner # noqa: E402, F401
# EE tasks are registered conditionally — they only exist in EE mode.
try:
import ee.api.src.tasks.compliance_scanner
import ee.api.src.tasks.compliance_scoring
import ee.api.src.tasks.retention # noqa: F401
app.conf.beat_schedule.update(
{
"check-scheduled-compliance-scans": {
"task": "src.tasks.compliance_scanner.check_scheduled_compliance_scans",
"schedule": crontab(hour="3", minute="0"),
},
"compute-daily-compliance-scores": {
"task": "src.tasks.compliance_scoring.compute_daily_scores",
"schedule": crontab(hour="4", minute="0"),
},
"run-retention-purge": {
"task": "src.tasks.retention.run_retention_purge",
"schedule": crontab(hour="2", minute="0"),
},
}
)
except ImportError:
pass

View File

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