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'),
},
},
});