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:
4
apps/admin-ui/.dockerignore
Normal file
4
apps/admin-ui/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
8
apps/admin-ui/.env.production
Normal file
8
apps/admin-ui/.env.production
Normal file
@@ -0,0 +1,8 @@
|
||||
# Production environment — loaded automatically by Vite during `vite build`.
|
||||
# Same-origin path: Caddy reverse-proxies /api/v1/* to the API container.
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
|
||||
# Google Tag Manager container ID (e.g. GTM-XXXXXXX)
|
||||
VITE_GTM_ID=
|
||||
# CMP site ID for dog-fooding our own consent banner
|
||||
VITE_CMP_SITE_ID=
|
||||
24
apps/admin-ui/.gitignore
vendored
Normal file
24
apps/admin-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
11
apps/admin-ui/Dockerfile
Normal file
11
apps/admin-ui/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx vite build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
73
apps/admin-ui/README.md
Normal file
73
apps/admin-ui/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
apps/admin-ui/eslint.config.js
Normal file
23
apps/admin-ui/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
43
apps/admin-ui/index.html
Normal file
43
apps/admin-ui/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1B3C7C" />
|
||||
<title>ConsentOS</title>
|
||||
|
||||
<!-- Google Consent Mode defaults — deny all until ConsentOS banner collects consent -->
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('consent', 'default', {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
functionality_storage: 'denied',
|
||||
personalization_storage: 'denied',
|
||||
security_storage: 'granted',
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
<script>
|
||||
(function(w,d,s,l,i){if(!i)return;w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','%VITE_GTM_ID%');
|
||||
</script>
|
||||
|
||||
<!-- Dog-fooding our own consent banner -->
|
||||
<script src="/banner/consent-loader.js"
|
||||
data-site-id="%VITE_CONSENTOS_SITE_ID%"
|
||||
data-api-base="/api/v1"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
apps/admin-ui/nginx.conf
Normal file
29
apps/admin-ui/nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
# Uses Docker's embedded DNS with a variable so nginx resolves at request
|
||||
# time rather than at startup — prevents crash if api is temporarily down.
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $upstream http://api:8000;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
5948
apps/admin-ui/package-lock.json
generated
Normal file
5948
apps/admin-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
apps/admin-ui/package.json
Normal file
57
apps/admin-ui/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@consentos/admin-ui",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "bash scripts/copy-banner.sh",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/sora": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
26
apps/admin-ui/public/_headers
Normal file
26
apps/admin-ui/public/_headers
Normal file
@@ -0,0 +1,26 @@
|
||||
# Cloudflare Pages custom headers
|
||||
# https://developers.cloudflare.com/pages/configuration/headers/
|
||||
|
||||
/consent-loader.js
|
||||
Access-Control-Allow-Origin: *
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
/consent-bundle.js
|
||||
Access-Control-Allow-Origin: *
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
/consent-bundle.js.map
|
||||
Access-Control-Allow-Origin: *
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
|
||||
/site-config-*.json
|
||||
Access-Control-Allow-Origin: *
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
Cache-Control: public, max-age=300
|
||||
|
||||
/translations-*.json
|
||||
Access-Control-Allow-Origin: *
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
Cache-Control: public, max-age=300
|
||||
5
apps/admin-ui/public/_redirects
Normal file
5
apps/admin-ui/public/_redirects
Normal file
@@ -0,0 +1,5 @@
|
||||
# Cloudflare Pages redirects
|
||||
# https://developers.cloudflare.com/pages/configuration/redirects/
|
||||
|
||||
# SPA fallback — must be LAST so static files (banner scripts, config JSON) are served directly
|
||||
/* /index.html 200
|
||||
5
apps/admin-ui/public/favicon.svg
Normal file
5
apps/admin-ui/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="11" fill="#1B3C7C"/>
|
||||
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
7
apps/admin-ui/public/logo-lockup.svg
Normal file
7
apps/admin-ui/public/logo-lockup.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="48" height="48" rx="11" fill="#1B3C7C"/>
|
||||
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
|
||||
<text x="62" y="32" font-family="Sora, system-ui, sans-serif" font-size="24" font-weight="600" fill="#1B3C7C" letter-spacing="-0.24">Consent</text>
|
||||
<text x="159" y="32" font-family="Sora, system-ui, sans-serif" font-size="24" font-weight="600" fill="#2C6AE4" letter-spacing="-0.24">OS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
5
apps/admin-ui/public/logo-mark.svg
Normal file
5
apps/admin-ui/public/logo-mark.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="11" fill="#1B3C7C"/>
|
||||
<path d="M 33.9 14.1 A 13.5 13.5 0 1 0 33.9 33.9" stroke="white" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="33.9" cy="33.9" r="4" fill="#4D8AFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
19
apps/admin-ui/scripts/copy-banner.sh
Executable file
19
apps/admin-ui/scripts/copy-banner.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ensure banner scripts exist in public/ before the admin-ui Vite build.
|
||||
# If the banner hasn't been built yet, build it first.
|
||||
set -euo pipefail
|
||||
|
||||
BANNER_DIR="../banner"
|
||||
BANNER_DIST="$BANNER_DIR/dist"
|
||||
|
||||
if [ ! -f "$BANNER_DIST/consent-loader.js" ]; then
|
||||
echo "[prebuild] Banner not built yet — building now..."
|
||||
(cd "$BANNER_DIR" && npm ci && npm run build)
|
||||
fi
|
||||
|
||||
echo "[prebuild] Copying banner scripts to public/"
|
||||
mkdir -p public
|
||||
cp "$BANNER_DIST/consent-loader.js" public/
|
||||
cp "$BANNER_DIST/consent-bundle.js" public/
|
||||
[ -f "$BANNER_DIST/consent-bundle.js.map" ] && cp "$BANNER_DIST/consent-bundle.js.map" public/
|
||||
echo "[prebuild] Done — $(ls public/consent-loader.js)"
|
||||
90
apps/admin-ui/src/App.tsx
Normal file
90
apps/admin-ui/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Layout from './components/Layout';
|
||||
import { trackPageView } from './services/analytics';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import ComplianceDashboardPage from './pages/ComplianceDashboardPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SiteDetailPage from './pages/SiteDetailPage';
|
||||
import SiteGroupDetailPage from './pages/SiteGroupDetailPage';
|
||||
import SitesPage from './pages/SitesPage';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { discoverExtensions, getPages } from './extensions/registry';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AppRoutes() {
|
||||
const { loadUser, isAuthenticated } = useAuthStore();
|
||||
const location = useLocation();
|
||||
const [extensionsReady, setExtensionsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
discoverExtensions().then(() => setExtensionsReady(true));
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const extensionPages = extensionsReady ? getPages() : [];
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/sites" element={<SitesPage />} />
|
||||
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
||||
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
||||
<Route path="/compliance" element={<ComplianceDashboardPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
{extensionPages
|
||||
.filter((p) => p.protected !== false)
|
||||
.map((p) => (
|
||||
<Route key={p.path} path={p.path} element={<p.component />} />
|
||||
))}
|
||||
</Route>
|
||||
{extensionPages
|
||||
.filter((p) => p.protected === false)
|
||||
.map((p) => (
|
||||
<Route key={p.path} path={p.path} element={<p.component />} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={isAuthenticated ? '/sites' : '/login'} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
19
apps/admin-ui/src/api/auth.ts
Normal file
19
apps/admin-ui/src/api/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TokenResponse, User } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function login(email: string, password: string): Promise<TokenResponse> {
|
||||
const { data } = await apiClient.post<TokenResponse>('/auth/login', { email, password });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshToken(token: string): Promise<TokenResponse> {
|
||||
const { data } = await apiClient.post<TokenResponse>('/auth/refresh', {
|
||||
refresh_token: token,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
return data;
|
||||
}
|
||||
121
apps/admin-ui/src/api/client.ts
Normal file
121
apps/admin-ui/src/api/client.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// ── Token storage helpers ──────────────────────────────────────────
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
function setAccessToken(token: string): void {
|
||||
localStorage.setItem('access_token', token);
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
function setRefreshToken(token: string): void {
|
||||
localStorage.setItem('refresh_token', token);
|
||||
}
|
||||
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
// ── Request interceptor: attach bearer token ───────────────────────
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: refresh-on-401 ──────────────────────────
|
||||
/**
|
||||
* When a request fails with 401, transparently attempt a single token
|
||||
* refresh using the stored refresh token. Concurrent 401s share the
|
||||
* same refresh promise so we don't hit ``/auth/refresh`` in parallel.
|
||||
*
|
||||
* If the refresh itself fails (no stored token, 401, or any other
|
||||
* error), clear stored tokens and redirect to the login page.
|
||||
*/
|
||||
type RetryableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
async function performRefresh(): Promise<string> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Use a bare axios call so we don't re-enter this interceptor.
|
||||
const { data } = await axios.post<{ access_token: string; refresh_token: string }>(
|
||||
`${API_BASE}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
setAccessToken(data.access_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as RetryableRequest | undefined;
|
||||
const status = error.response?.status;
|
||||
|
||||
// Not a 401, or we've already retried — give up and propagate.
|
||||
if (status !== 401 || !original || original._retry) {
|
||||
if (status === 401) {
|
||||
clearTokens();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
// Don't loop on the refresh endpoint itself.
|
||||
if (original.url?.includes('/auth/refresh')) {
|
||||
clearTokens();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
|
||||
try {
|
||||
// Coalesce concurrent refresh attempts.
|
||||
refreshPromise = refreshPromise ?? performRefresh();
|
||||
const newAccess = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
original.headers = original.headers ?? {};
|
||||
original.headers.Authorization = `Bearer ${newAccess}`;
|
||||
return apiClient.request(original);
|
||||
} catch (refreshError) {
|
||||
refreshPromise = null;
|
||||
clearTokens();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(
|
||||
refreshError instanceof Error ? refreshError : new Error(String(refreshError)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
37
apps/admin-ui/src/api/compliance-scores.ts
Normal file
37
apps/admin-ui/src/api/compliance-scores.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
ComplianceScoreSummary,
|
||||
ComplianceScoreTrendResponse,
|
||||
ValidationResultResponse,
|
||||
} from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function getComplianceScoreSummary(
|
||||
siteId: string,
|
||||
): Promise<ComplianceScoreSummary> {
|
||||
const { data } = await apiClient.get<ComplianceScoreSummary>(
|
||||
`/sites/${siteId}/compliance-scores`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getComplianceScoreTrend(
|
||||
siteId: string,
|
||||
params?: { framework?: string; days?: number },
|
||||
): Promise<ComplianceScoreTrendResponse> {
|
||||
const { data } = await apiClient.get<ComplianceScoreTrendResponse>(
|
||||
`/sites/${siteId}/compliance-scores/trend`,
|
||||
{ params },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function triggerConsentValidation(
|
||||
siteId: string,
|
||||
url?: string,
|
||||
): Promise<ValidationResultResponse> {
|
||||
const { data } = await apiClient.post<ValidationResultResponse>(
|
||||
`/sites/${siteId}/validate-consent`,
|
||||
url ? { url } : null,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
18
apps/admin-ui/src/api/compliance.ts
Normal file
18
apps/admin-ui/src/api/compliance.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ComplianceCheckResponse, ComplianceFramework } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function runComplianceCheck(
|
||||
siteId: string,
|
||||
frameworks?: ComplianceFramework[],
|
||||
): Promise<ComplianceCheckResponse> {
|
||||
const { data } = await apiClient.post<ComplianceCheckResponse>(
|
||||
`/compliance/check/${siteId}`,
|
||||
frameworks ? { frameworks } : {},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listFrameworks(): Promise<{ frameworks: ComplianceFramework[] }> {
|
||||
const { data } = await apiClient.get<{ frameworks: ComplianceFramework[] }>('/compliance/frameworks');
|
||||
return data;
|
||||
}
|
||||
48
apps/admin-ui/src/api/cookies.ts
Normal file
48
apps/admin-ui/src/api/cookies.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { AllowListEntry, Cookie, CookieCategory } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function listCategories(): Promise<CookieCategory[]> {
|
||||
const { data } = await apiClient.get<CookieCategory[]>('/cookies/categories');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listCookies(
|
||||
siteId: string,
|
||||
params?: { review_status?: string; category_id?: string },
|
||||
): Promise<Cookie[]> {
|
||||
const { data } = await apiClient.get<Cookie[]>(`/cookies/sites/${siteId}`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateCookie(
|
||||
siteId: string,
|
||||
cookieId: string,
|
||||
body: Partial<Cookie>,
|
||||
): Promise<Cookie> {
|
||||
const { data } = await apiClient.patch<Cookie>(`/cookies/sites/${siteId}/${cookieId}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCookieSummary(
|
||||
siteId: string,
|
||||
): Promise<{ total: number; by_status: Record<string, number>; by_category: Record<string, number>; uncategorised: number }> {
|
||||
const { data } = await apiClient.get(`/cookies/sites/${siteId}/summary`);
|
||||
return data as { total: number; by_status: Record<string, number>; by_category: Record<string, number>; uncategorised: number };
|
||||
}
|
||||
|
||||
export async function listAllowList(siteId: string): Promise<AllowListEntry[]> {
|
||||
const { data } = await apiClient.get<AllowListEntry[]>(`/cookies/sites/${siteId}/allow-list`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createAllowListEntry(
|
||||
siteId: string,
|
||||
body: { name_pattern: string; domain_pattern: string; category_id: string; description?: string },
|
||||
): Promise<AllowListEntry> {
|
||||
const { data } = await apiClient.post<AllowListEntry>(`/cookies/sites/${siteId}/allow-list`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteAllowListEntry(siteId: string, entryId: string): Promise<void> {
|
||||
await apiClient.delete(`/cookies/sites/${siteId}/allow-list/${entryId}`);
|
||||
}
|
||||
12
apps/admin-ui/src/api/org-config.ts
Normal file
12
apps/admin-ui/src/api/org-config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { OrgConfig } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function getOrgConfig(): Promise<OrgConfig> {
|
||||
const { data } = await apiClient.get<OrgConfig>('/org-config/');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateOrgConfig(body: Partial<OrgConfig>): Promise<OrgConfig> {
|
||||
const { data } = await apiClient.put<OrgConfig>('/org-config/', body);
|
||||
return data;
|
||||
}
|
||||
31
apps/admin-ui/src/api/scanner.ts
Normal file
31
apps/admin-ui/src/api/scanner.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ScanDiff, ScanJob, ScanJobDetail } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function triggerScan(
|
||||
siteId: string,
|
||||
maxPages: number = 50,
|
||||
): Promise<ScanJob> {
|
||||
const { data } = await apiClient.post<ScanJob>('/scanner/scans', {
|
||||
site_id: siteId,
|
||||
max_pages: maxPages,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listScans(
|
||||
siteId: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<ScanJob[]> {
|
||||
const { data } = await apiClient.get<ScanJob[]>(`/scanner/scans/site/${siteId}`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getScan(scanId: string): Promise<ScanJobDetail> {
|
||||
const { data } = await apiClient.get<ScanJobDetail>(`/scanner/scans/${scanId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getScanDiff(scanId: string): Promise<ScanDiff> {
|
||||
const { data } = await apiClient.get<ScanDiff>(`/scanner/scans/${scanId}/diff`);
|
||||
return data;
|
||||
}
|
||||
18
apps/admin-ui/src/api/site-group-config.ts
Normal file
18
apps/admin-ui/src/api/site-group-config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { SiteGroupConfig } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function getSiteGroupConfig(groupId: string): Promise<SiteGroupConfig> {
|
||||
const { data } = await apiClient.get<SiteGroupConfig>(`/site-groups/${groupId}/config`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSiteGroupConfig(
|
||||
groupId: string,
|
||||
body: Partial<SiteGroupConfig>,
|
||||
): Promise<SiteGroupConfig> {
|
||||
const { data } = await apiClient.put<SiteGroupConfig>(
|
||||
`/site-groups/${groupId}/config`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
32
apps/admin-ui/src/api/site-groups.ts
Normal file
32
apps/admin-ui/src/api/site-groups.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { SiteGroup } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function listSiteGroups(): Promise<SiteGroup[]> {
|
||||
const { data } = await apiClient.get<SiteGroup[]>('/site-groups/');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSiteGroup(id: string): Promise<SiteGroup> {
|
||||
const { data } = await apiClient.get<SiteGroup>(`/site-groups/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createSiteGroup(body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<SiteGroup> {
|
||||
const { data } = await apiClient.post<SiteGroup>('/site-groups/', body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSiteGroup(
|
||||
id: string,
|
||||
body: { name?: string; description?: string },
|
||||
): Promise<SiteGroup> {
|
||||
const { data } = await apiClient.patch<SiteGroup>(`/site-groups/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteSiteGroup(id: string): Promise<void> {
|
||||
await apiClient.delete(`/site-groups/${id}`);
|
||||
}
|
||||
50
apps/admin-ui/src/api/sites.ts
Normal file
50
apps/admin-ui/src/api/sites.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ConfigInheritanceResponse, Site, SiteConfig } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function listSites(): Promise<Site[]> {
|
||||
const { data } = await apiClient.get<Site[]>('/sites/');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSite(id: string): Promise<Site> {
|
||||
const { data } = await apiClient.get<Site>(`/sites/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createSite(body: {
|
||||
domain: string;
|
||||
display_name: string;
|
||||
site_group_id?: string;
|
||||
}): Promise<Site> {
|
||||
const { data } = await apiClient.post<Site>('/sites/', body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSite(id: string, body: Partial<Site>): Promise<Site> {
|
||||
const { data } = await apiClient.patch<Site>(`/sites/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteSite(id: string): Promise<void> {
|
||||
await apiClient.delete(`/sites/${id}`);
|
||||
}
|
||||
|
||||
export async function getSiteConfig(siteId: string): Promise<SiteConfig> {
|
||||
const { data } = await apiClient.get<SiteConfig>(`/sites/${siteId}/config`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSiteConfig(
|
||||
siteId: string,
|
||||
body: Partial<SiteConfig>,
|
||||
): Promise<SiteConfig> {
|
||||
const { data } = await apiClient.put<SiteConfig>(`/sites/${siteId}/config`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getConfigInheritance(siteId: string): Promise<ConfigInheritanceResponse> {
|
||||
const { data } = await apiClient.get<ConfigInheritanceResponse>(
|
||||
`/config/sites/${siteId}/inheritance`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
36
apps/admin-ui/src/api/translations.ts
Normal file
36
apps/admin-ui/src/api/translations.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Translation } from '../types/api';
|
||||
import apiClient from './client';
|
||||
|
||||
export async function listTranslations(siteId: string): Promise<Translation[]> {
|
||||
const { data } = await apiClient.get<Translation[]>(`/sites/${siteId}/translations/`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTranslation(siteId: string, locale: string): Promise<Translation> {
|
||||
const { data } = await apiClient.get<Translation>(`/sites/${siteId}/translations/${locale}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTranslation(
|
||||
siteId: string,
|
||||
body: { locale: string; strings: Record<string, string> },
|
||||
): Promise<Translation> {
|
||||
const { data } = await apiClient.post<Translation>(`/sites/${siteId}/translations/`, body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTranslation(
|
||||
siteId: string,
|
||||
locale: string,
|
||||
body: { strings: Record<string, string> },
|
||||
): Promise<Translation> {
|
||||
const { data } = await apiClient.put<Translation>(
|
||||
`/sites/${siteId}/translations/${locale}`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTranslation(siteId: string, locale: string): Promise<void> {
|
||||
await apiClient.delete(`/sites/${siteId}/translations/${locale}`);
|
||||
}
|
||||
476
apps/admin-ui/src/components/BannerBuilderTab.tsx
Normal file
476
apps/admin-ui/src/components/BannerBuilderTab.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { trackConfigChange } from '../services/analytics';
|
||||
import type { BannerConfig, ButtonConfig } from '../types/api';
|
||||
import { Button } from './ui/button.tsx';
|
||||
import { Card, CardContent } from './ui/card.tsx';
|
||||
import { Alert } from './ui/alert.tsx';
|
||||
import { Select } from './ui/select.tsx';
|
||||
import { TabGroup } from './ui/tab-group.tsx';
|
||||
import BannerPreview from './BannerPreview';
|
||||
|
||||
type DisplayMode = 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
|
||||
type CornerPosition = 'left' | 'right';
|
||||
type Viewport = 'desktop' | 'mobile';
|
||||
|
||||
const DISPLAY_MODES: { value: DisplayMode; label: string }[] = [
|
||||
{ value: 'bottom_banner', label: 'Bottom banner' },
|
||||
{ value: 'top_banner', label: 'Top banner' },
|
||||
{ value: 'overlay', label: 'Overlay (modal)' },
|
||||
{ value: 'corner_popup', label: 'Corner popup' },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: 'system-ui', label: 'System default' },
|
||||
{ value: "'Inter', sans-serif", label: 'Inter' },
|
||||
{ value: "'Roboto', sans-serif", label: 'Roboto' },
|
||||
{ value: "'Open Sans', sans-serif", label: 'Open Sans' },
|
||||
{ value: "'Lato', sans-serif", label: 'Lato' },
|
||||
{ value: "Georgia, serif", label: 'Georgia (serif)' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** Unique key for cache invalidation (e.g. ['sites', siteId, 'config'] or ['org-config']) */
|
||||
configQueryKey: string[];
|
||||
/** The config object containing banner_config */
|
||||
config: { banner_config: BannerConfig | null } | null;
|
||||
/** Function to save the updated banner config */
|
||||
onSave: (body: { banner_config: BannerConfig }) => Promise<unknown>;
|
||||
/** Optional domain for the preview iframe */
|
||||
siteDomain?: string | null;
|
||||
}
|
||||
|
||||
interface Defaults {
|
||||
primaryColour: string;
|
||||
backgroundColour: string;
|
||||
textColour: string;
|
||||
buttonStyle: 'filled' | 'outline';
|
||||
fontFamily: string;
|
||||
borderRadius: number;
|
||||
showRejectAll: boolean;
|
||||
showManagePreferences: boolean;
|
||||
showCloseButton: boolean;
|
||||
showLogo: boolean;
|
||||
logoUrl: string;
|
||||
showCookieCount: boolean;
|
||||
displayMode: DisplayMode;
|
||||
cornerPosition: CornerPosition;
|
||||
acceptButton: ButtonConfig;
|
||||
rejectButton: ButtonConfig;
|
||||
manageButton: ButtonConfig;
|
||||
}
|
||||
|
||||
function getDefaults(config: { banner_config: BannerConfig | null } | null): Defaults {
|
||||
const bc = config?.banner_config;
|
||||
return {
|
||||
primaryColour: bc?.primaryColour ?? '#2563eb',
|
||||
backgroundColour: bc?.backgroundColour ?? '#ffffff',
|
||||
textColour: bc?.textColour ?? '#1a1a2e',
|
||||
buttonStyle: bc?.buttonStyle ?? 'filled',
|
||||
fontFamily: bc?.fontFamily ?? 'system-ui',
|
||||
borderRadius: bc?.borderRadius ?? 6,
|
||||
showRejectAll: bc?.showRejectAll ?? true,
|
||||
showManagePreferences: bc?.showManagePreferences ?? true,
|
||||
showCloseButton: bc?.showCloseButton ?? false,
|
||||
showLogo: bc?.showLogo ?? false,
|
||||
logoUrl: bc?.logoUrl ?? '',
|
||||
showCookieCount: bc?.showCookieCount ?? false,
|
||||
displayMode: (bc?.displayMode as DisplayMode) ?? 'bottom_banner',
|
||||
cornerPosition: (bc?.cornerPosition as CornerPosition) ?? 'right',
|
||||
acceptButton: bc?.acceptButton ?? {},
|
||||
rejectButton: bc?.rejectButton ?? {},
|
||||
manageButton: bc?.manageButton ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export default function BannerBuilderTab({ configQueryKey, config, onSave, siteDomain }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const defaults = useMemo(() => getDefaults(config), [config]);
|
||||
|
||||
// Theme state
|
||||
const [primaryColour, setPrimaryColour] = useState(defaults.primaryColour);
|
||||
const [backgroundColour, setBackgroundColour] = useState(defaults.backgroundColour);
|
||||
const [textColour, setTextColour] = useState(defaults.textColour);
|
||||
const [buttonStyle, setButtonStyle] = useState(defaults.buttonStyle);
|
||||
const [fontFamily, setFontFamily] = useState(defaults.fontFamily);
|
||||
const [borderRadius, setBorderRadius] = useState(defaults.borderRadius);
|
||||
|
||||
// Layout state
|
||||
const [showRejectAll, setShowRejectAll] = useState(defaults.showRejectAll);
|
||||
const [showManagePreferences, setShowManagePreferences] = useState(defaults.showManagePreferences);
|
||||
const [showCloseButton, setShowCloseButton] = useState(defaults.showCloseButton);
|
||||
const [showLogo, setShowLogo] = useState(defaults.showLogo);
|
||||
const [logoUrl, setLogoUrl] = useState(defaults.logoUrl);
|
||||
const [showCookieCount, setShowCookieCount] = useState(defaults.showCookieCount);
|
||||
|
||||
// Display mode and viewport
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>(defaults.displayMode);
|
||||
const [cornerPosition, setCornerPosition] = useState<CornerPosition>(defaults.cornerPosition);
|
||||
const [viewport, setViewport] = useState<Viewport>('desktop');
|
||||
|
||||
// Per-button styling
|
||||
const [acceptButton, setAcceptButton] = useState<ButtonConfig>(defaults.acceptButton);
|
||||
const [rejectButton, setRejectButton] = useState<ButtonConfig>(defaults.rejectButton);
|
||||
const [manageButton, setManageButton] = useState<ButtonConfig>(defaults.manageButton);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: { banner_config: BannerConfig }) => onSave(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: configQueryKey });
|
||||
trackConfigChange('banner_config');
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const bannerConfig: BannerConfig = useMemo(
|
||||
() => ({
|
||||
primaryColour,
|
||||
backgroundColour,
|
||||
textColour,
|
||||
buttonStyle,
|
||||
fontFamily,
|
||||
borderRadius,
|
||||
showRejectAll,
|
||||
showManagePreferences,
|
||||
showCloseButton,
|
||||
showLogo,
|
||||
logoUrl: logoUrl || undefined,
|
||||
showCookieCount,
|
||||
cornerPosition,
|
||||
acceptButton: Object.keys(acceptButton).length > 0 ? acceptButton : undefined,
|
||||
rejectButton: Object.keys(rejectButton).length > 0 ? rejectButton : undefined,
|
||||
manageButton: Object.keys(manageButton).length > 0 ? manageButton : undefined,
|
||||
}),
|
||||
[
|
||||
primaryColour, backgroundColour, textColour, buttonStyle, fontFamily,
|
||||
borderRadius, showRejectAll, showManagePreferences, showCloseButton,
|
||||
showLogo, logoUrl, showCookieCount, cornerPosition,
|
||||
acceptButton, rejectButton, manageButton,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
mutation.mutate({
|
||||
banner_config: { ...bannerConfig, displayMode },
|
||||
});
|
||||
}, [mutation, bannerConfig, displayMode]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-6" data-testid="banner-builder">
|
||||
{/* Left panel — controls */}
|
||||
<div className="w-80 shrink-0 space-y-5 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||||
{/* Display mode */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Display mode</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DISPLAY_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setDisplayMode(mode.value)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
|
||||
displayMode === mode.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-mist text-text-secondary hover:bg-mist/80'
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Corner position — only shown for corner_popup */}
|
||||
{displayMode === 'corner_popup' && (
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">Position</label>
|
||||
<div className="flex gap-2">
|
||||
{(['left', 'right'] as const).map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => setCornerPosition(pos)}
|
||||
className={`flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||
cornerPosition === pos
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-mist text-text-secondary hover:bg-mist/80'
|
||||
}`}
|
||||
>
|
||||
{pos.charAt(0).toUpperCase() + pos.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Theme</h3>
|
||||
<div className="space-y-3">
|
||||
<ColourField label="Primary colour" value={primaryColour} onChange={setPrimaryColour} />
|
||||
<ColourField label="Background" value={backgroundColour} onChange={setBackgroundColour} />
|
||||
<ColourField label="Text colour" value={textColour} onChange={setTextColour} />
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">Font</label>
|
||||
<Select
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">
|
||||
Border radius ({borderRadius}px)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={borderRadius}
|
||||
onChange={(e) => setBorderRadius(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">Default button style</label>
|
||||
<div className="flex gap-2">
|
||||
{(['filled', 'outline'] as const).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => setButtonStyle(style)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||
buttonStyle === style
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-mist text-text-secondary hover:bg-mist/80'
|
||||
}`}
|
||||
>
|
||||
{style.charAt(0).toUpperCase() + style.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Button styling */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Button styling</h3>
|
||||
<p className="mb-3 text-xs text-text-secondary">
|
||||
Override colours per button, or leave blank to use the theme defaults.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<ButtonStyleEditor
|
||||
label="Accept button"
|
||||
config={acceptButton}
|
||||
onChange={setAcceptButton}
|
||||
defaults={{ backgroundColour: primaryColour, textColour: '#ffffff', style: buttonStyle }}
|
||||
/>
|
||||
{showRejectAll && (
|
||||
<ButtonStyleEditor
|
||||
label="Reject button"
|
||||
config={rejectButton}
|
||||
onChange={setRejectButton}
|
||||
defaults={{ backgroundColour: 'transparent', textColour, style: 'outline' }}
|
||||
/>
|
||||
)}
|
||||
{showManagePreferences && (
|
||||
<ButtonStyleEditor
|
||||
label="Manage preferences"
|
||||
config={manageButton}
|
||||
onChange={setManageButton}
|
||||
defaults={{ backgroundColour: 'transparent', textColour, style: 'outline' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Layout */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="mb-3 font-heading text-sm font-semibold text-foreground">Layout</h3>
|
||||
<div className="space-y-2.5">
|
||||
<ToggleField label="Show 'Reject all' button" checked={showRejectAll} onChange={setShowRejectAll} />
|
||||
<ToggleField label="Show 'Manage preferences'" checked={showManagePreferences} onChange={setShowManagePreferences} />
|
||||
<ToggleField label="Show close button" checked={showCloseButton} onChange={setShowCloseButton} />
|
||||
<ToggleField label="Show cookie count" checked={showCookieCount} onChange={setShowCookieCount} />
|
||||
<ToggleField label="Show logo" checked={showLogo} onChange={setShowLogo} />
|
||||
{showLogo && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">Logo URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder="https://example.com/logo.svg"
|
||||
className="w-full rounded-lg border border-border px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={mutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save banner'}
|
||||
</Button>
|
||||
</div>
|
||||
{saved && <Alert variant="success">Saved successfully</Alert>}
|
||||
{mutation.isError && <Alert variant="error">Failed to save. Please try again.</Alert>}
|
||||
</div>
|
||||
|
||||
{/* Right panel — preview */}
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Live preview</h3>
|
||||
<TabGroup
|
||||
options={[
|
||||
{ value: 'desktop', label: 'Desktop' },
|
||||
{ value: 'mobile', label: 'Mobile' },
|
||||
]}
|
||||
value={viewport}
|
||||
onChange={(v) => setViewport(v as Viewport)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BannerPreview
|
||||
bannerConfig={bannerConfig}
|
||||
displayMode={displayMode}
|
||||
cornerPosition={cornerPosition}
|
||||
viewport={viewport}
|
||||
privacyPolicyUrl={(config as Record<string, unknown>)?.privacy_policy_url as string ?? null}
|
||||
siteUrl={siteDomain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper components ─────────────────────────────────────────────── */
|
||||
|
||||
function ColourField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-secondary">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded border border-border px-2 py-0.5 text-xs font-mono text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-copper"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonStyleEditor({
|
||||
label,
|
||||
config,
|
||||
onChange,
|
||||
defaults,
|
||||
}: {
|
||||
label: string;
|
||||
config: ButtonConfig;
|
||||
onChange: (c: ButtonConfig) => void;
|
||||
defaults: { backgroundColour: string; textColour: string; style: string };
|
||||
}) {
|
||||
const update = (patch: Partial<ButtonConfig>) => onChange({ ...config, ...patch });
|
||||
const bgColour = config.backgroundColour ?? defaults.backgroundColour;
|
||||
const txtColour = config.textColour ?? defaults.textColour;
|
||||
const style = config.style ?? defaults.style;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">{label}</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
{(['filled', 'outline', 'text'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => update({ style: s })}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition ${
|
||||
style === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-mist text-text-secondary hover:bg-mist/80'
|
||||
}`}
|
||||
>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={bgColour}
|
||||
onChange={(e) => update({ backgroundColour: e.target.value })}
|
||||
className="h-6 w-6 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">Background</span>
|
||||
<input
|
||||
type="color"
|
||||
value={txtColour}
|
||||
onChange={(e) => update({ textColour: e.target.value })}
|
||||
className="ml-auto h-6 w-6 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">Text</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
574
apps/admin-ui/src/components/BannerPreview.tsx
Normal file
574
apps/admin-ui/src/components/BannerPreview.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BannerConfig, ButtonConfig } from '../types/api';
|
||||
|
||||
type DisplayMode = 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
|
||||
type CornerPosition = 'left' | 'right';
|
||||
type Viewport = 'desktop' | 'mobile';
|
||||
|
||||
/* ── Default text values ─────────────────────────────────────────────── */
|
||||
|
||||
const DEFAULT_TITLE = 'We use cookies';
|
||||
const DEFAULT_DESCRIPTION =
|
||||
'We use cookies and similar technologies to enhance your browsing experience, ' +
|
||||
'analyse site traffic, and personalise content. You can choose which categories to allow.';
|
||||
const DEFAULT_ACCEPT_ALL = 'Accept all';
|
||||
const DEFAULT_REJECT_ALL = 'Reject all';
|
||||
const DEFAULT_MANAGE_PREFERENCES = 'Manage preferences';
|
||||
const DEFAULT_SAVE_PREFERENCES = 'Save preferences';
|
||||
|
||||
interface Props {
|
||||
bannerConfig: BannerConfig;
|
||||
displayMode: DisplayMode;
|
||||
cornerPosition?: CornerPosition;
|
||||
viewport: Viewport;
|
||||
privacyPolicyUrl: string | null;
|
||||
siteUrl?: string | null;
|
||||
previewLocale?: string;
|
||||
}
|
||||
|
||||
export default function BannerPreview({
|
||||
bannerConfig,
|
||||
displayMode,
|
||||
cornerPosition = 'right',
|
||||
viewport,
|
||||
privacyPolicyUrl,
|
||||
siteUrl,
|
||||
previewLocale,
|
||||
}: Props) {
|
||||
const [iframeLoadFailed, setIframeLoadFailed] = useState(false);
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
const siteIframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const bannerSrcdoc = useMemo(
|
||||
() => buildBannerOnlyHtml(bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale),
|
||||
[bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale],
|
||||
);
|
||||
const fallbackSrcdoc = useMemo(
|
||||
() => buildPreviewHtml(bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale),
|
||||
[bannerConfig, displayMode, cornerPosition, privacyPolicyUrl, previewLocale],
|
||||
);
|
||||
|
||||
const fullSiteUrl = useMemo(() => {
|
||||
if (!siteUrl) return null;
|
||||
// Ensure the URL has a protocol
|
||||
if (siteUrl.startsWith('http://') || siteUrl.startsWith('https://')) return siteUrl;
|
||||
return `https://${siteUrl}`;
|
||||
}, [siteUrl]);
|
||||
|
||||
// Reset state when the site URL changes
|
||||
useEffect(() => {
|
||||
setIframeLoadFailed(false);
|
||||
setIframeLoaded(false);
|
||||
}, [fullSiteUrl]);
|
||||
|
||||
const handleSiteIframeLoad = useCallback(() => {
|
||||
// Check if the iframe actually loaded content by trying to access it
|
||||
// If X-Frame-Options or CSP blocks it, the iframe will be blank
|
||||
const iframe = siteIframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
try {
|
||||
// Try to detect if the iframe loaded — accessing contentDocument will throw
|
||||
// for cross-origin frames, but that's fine (it means it loaded)
|
||||
// If the iframe is blank/error, some browsers fire load anyway
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc && doc.body && doc.body.innerHTML === '') {
|
||||
// Empty body might mean it was blocked
|
||||
setIframeLoadFailed(true);
|
||||
} else {
|
||||
setIframeLoaded(true);
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin — means the site loaded successfully
|
||||
setIframeLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSiteIframeError = useCallback(() => {
|
||||
setIframeLoadFailed(true);
|
||||
}, []);
|
||||
|
||||
const width = viewport === 'mobile' ? 375 : '100%';
|
||||
const useLiveSite = fullSiteUrl && !iframeLoadFailed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border border-border bg-mist"
|
||||
style={{ height: 500 }}
|
||||
data-testid="banner-preview"
|
||||
>
|
||||
{useLiveSite ? (
|
||||
<>
|
||||
{/* Live site iframe (background) */}
|
||||
<iframe
|
||||
ref={siteIframeRef}
|
||||
src={fullSiteUrl}
|
||||
title="Site preview"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
onLoad={handleSiteIframeLoad}
|
||||
onError={handleSiteIframeError}
|
||||
style={{
|
||||
width,
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
margin: viewport === 'mobile' ? '0 auto' : undefined,
|
||||
display: 'block',
|
||||
transition: 'width 0.3s ease',
|
||||
opacity: iframeLoaded ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
{/* Banner overlay on top of the live site */}
|
||||
<iframe
|
||||
srcDoc={bannerSrcdoc}
|
||||
sandbox="allow-scripts"
|
||||
title="Banner preview"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: viewport === 'mobile' ? 375 : '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
margin: viewport === 'mobile' ? '0 auto' : undefined,
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
{!iframeLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-mist/80">
|
||||
<p className="text-sm text-text-secondary">Loading site preview…</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Fallback: self-contained preview with placeholder content */
|
||||
<iframe
|
||||
srcDoc={fallbackSrcdoc}
|
||||
sandbox="allow-scripts"
|
||||
title="Banner preview"
|
||||
style={{
|
||||
width,
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
margin: viewport === 'mobile' ? '0 auto' : undefined,
|
||||
display: 'block',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{iframeLoadFailed && fullSiteUrl && (
|
||||
<div className="absolute bottom-2 left-2 rounded bg-status-warning-bg px-2 py-1 text-xs text-status-warning-fg ring-1 ring-status-warning-fg/20">
|
||||
Could not load site preview — the site may block iframe embedding
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Banner-only HTML (transparent background, overlay on live site) ── */
|
||||
|
||||
function buildBannerOnlyHtml(
|
||||
bc: BannerConfig,
|
||||
displayMode: DisplayMode,
|
||||
cornerPosition: CornerPosition,
|
||||
privacyUrl: string | null,
|
||||
previewLocale?: string,
|
||||
): string {
|
||||
const bg = bc.backgroundColour ?? '#ffffff';
|
||||
const text = bc.textColour ?? '#1a1a2e';
|
||||
const primary = bc.primaryColour ?? '#2563eb';
|
||||
const font = bc.fontFamily ?? 'system-ui';
|
||||
const radius = bc.borderRadius ?? 6;
|
||||
const defaultButtonStyle = bc.buttonStyle ?? 'filled';
|
||||
|
||||
const positionStyles = getPositionStyles(displayMode, cornerPosition, radius);
|
||||
const { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText } =
|
||||
buildBannerParts(bc, primary, text, radius, privacyUrl, defaultButtonStyle);
|
||||
|
||||
const fontLink = bc.customFontUrl
|
||||
? `<link rel="stylesheet" href="${escapeHtml(bc.customFontUrl)}">`
|
||||
: '';
|
||||
|
||||
const langAttr = previewLocale ? escapeHtml(previewLocale) : 'en';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${langAttr}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
${fontLink}
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: transparent; }
|
||||
|
||||
.consentos-banner {
|
||||
${positionStyles}
|
||||
background: ${bg};
|
||||
color: ${text};
|
||||
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: ${displayMode === 'overlay' || displayMode === 'corner_popup' ? radius + 'px' : '0'};
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.consentos-banner__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cmp-logo { height: 28px; margin-bottom: 10px; display: block; }
|
||||
.consentos-banner__title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||
.consentos-banner__description { margin-bottom: 16px; opacity: 0.85; }
|
||||
.consentos-banner__link { color: ${primary}; text-decoration: underline; }
|
||||
.cmp-cookie-count { display: block; font-size: 12px; opacity: 0.6; margin-bottom: 12px; }
|
||||
.consentos-banner__actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.cmp-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: ${radius}px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cmp-close {
|
||||
position: absolute; top: 12px; right: 12px;
|
||||
background: none; border: none; font-size: 22px;
|
||||
cursor: pointer; color: ${text}; opacity: 0.5; line-height: 1;
|
||||
}
|
||||
|
||||
.cmp-overlay-bg {
|
||||
display: ${displayMode === 'overlay' ? 'block' : 'none'};
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.consentos-banner__actions { flex-direction: column; }
|
||||
.cmp-btn { width: 100%; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cmp-overlay-bg"></div>
|
||||
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
|
||||
<div class="consentos-banner__content">
|
||||
${closeBtn}
|
||||
${logoHtml}
|
||||
<p class="consentos-banner__title">${escapeHtml(titleText)}</p>
|
||||
<p class="consentos-banner__description">
|
||||
${escapeHtml(descriptionText)}${privacyLink}
|
||||
</p>
|
||||
${cookieCount}
|
||||
<div class="consentos-banner__actions">
|
||||
${rejectBtn}
|
||||
${manageBtn}
|
||||
${acceptBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ── Full preview HTML (with placeholder page content, used as fallback) ── */
|
||||
|
||||
function buildPreviewHtml(
|
||||
bc: BannerConfig,
|
||||
displayMode: DisplayMode,
|
||||
cornerPosition: CornerPosition,
|
||||
privacyUrl: string | null,
|
||||
previewLocale?: string,
|
||||
): string {
|
||||
const bg = bc.backgroundColour ?? '#ffffff';
|
||||
const text = bc.textColour ?? '#1a1a2e';
|
||||
const primary = bc.primaryColour ?? '#2563eb';
|
||||
const font = bc.fontFamily ?? 'system-ui';
|
||||
const radius = bc.borderRadius ?? 6;
|
||||
const defaultButtonStyle = bc.buttonStyle ?? 'filled';
|
||||
|
||||
const positionStyles = getPositionStyles(displayMode, cornerPosition, radius);
|
||||
const { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText, savePreferencesText } =
|
||||
buildBannerParts(bc, primary, text, radius, privacyUrl, defaultButtonStyle);
|
||||
|
||||
const fontLink = bc.customFontUrl
|
||||
? `<link rel="stylesheet" href="${escapeHtml(bc.customFontUrl)}">`
|
||||
: '';
|
||||
|
||||
const langAttr = previewLocale ? escapeHtml(previewLocale) : 'en';
|
||||
|
||||
// Build the save preferences button with accept button styling
|
||||
const acceptStyle = buildButtonStyle(bc.acceptButton, defaultButtonStyle, primary, '#ffffff', 'none', radius);
|
||||
const saveBtnHtml = `<button class="cmp-btn cmp-btn--primary cmp-btn--save" style="${acceptStyle}">${escapeHtml(savePreferencesText)}</button>`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${langAttr}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
${fontLink}
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #f3f4f6;
|
||||
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 32px 24px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.page-content h2 { color: #374151; font-size: 18px; margin-bottom: 12px; }
|
||||
.page-content p { margin-bottom: 12px; }
|
||||
|
||||
.consentos-banner {
|
||||
${positionStyles}
|
||||
background: ${bg};
|
||||
color: ${text};
|
||||
font-family: ${font}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: ${displayMode === 'overlay' || displayMode === 'corner_popup' ? radius + 'px' : '0'};
|
||||
}
|
||||
|
||||
.consentos-banner__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cmp-logo { height: 28px; margin-bottom: 10px; display: block; }
|
||||
.consentos-banner__title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||
.consentos-banner__description { margin-bottom: 16px; opacity: 0.85; }
|
||||
.consentos-banner__link { color: ${primary}; text-decoration: underline; }
|
||||
.cmp-cookie-count { display: block; font-size: 12px; opacity: 0.6; margin-bottom: 12px; }
|
||||
.consentos-banner__actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.cmp-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: ${radius}px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cmp-close {
|
||||
position: absolute; top: 12px; right: 12px;
|
||||
background: none; border: none; font-size: 22px;
|
||||
cursor: pointer; color: ${text}; opacity: 0.5; line-height: 1;
|
||||
}
|
||||
|
||||
.consentos-banner__categories { display: none; margin-bottom: 16px; }
|
||||
|
||||
.cmp-category {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.cmp-category__info { display: flex; flex-direction: column; flex: 1; margin-right: 12px; }
|
||||
.cmp-category__name { font-weight: 500; }
|
||||
.cmp-category__desc { font-size: 12px; opacity: 0.7; }
|
||||
.cmp-category input[type="checkbox"] { width: 18px; height: 18px; accent-color: ${primary}; }
|
||||
.cmp-btn--save { margin-top: 12px; width: 100%; }
|
||||
|
||||
.cmp-overlay-bg {
|
||||
display: ${displayMode === 'overlay' ? 'block' : 'none'};
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.consentos-banner__actions { flex-direction: column; }
|
||||
.cmp-btn { width: 100%; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-content">
|
||||
<h2>Example page</h2>
|
||||
<p>This is a preview of how the consent banner will appear on your site. The banner is rendered with your current theme and layout settings.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
||||
</div>
|
||||
|
||||
<div class="cmp-overlay-bg"></div>
|
||||
|
||||
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
|
||||
<div class="consentos-banner__content">
|
||||
${closeBtn}
|
||||
${logoHtml}
|
||||
<p class="consentos-banner__title">${escapeHtml(titleText)}</p>
|
||||
<p class="consentos-banner__description">
|
||||
${escapeHtml(descriptionText)}${privacyLink}
|
||||
</p>
|
||||
${cookieCount}
|
||||
<div class="consentos-banner__categories" id="cmp-prefs">
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name">Necessary</span>
|
||||
<span class="cmp-category__desc">Essential for the website to function. Always active.</span>
|
||||
</div>
|
||||
<input type="checkbox" checked disabled />
|
||||
</label>
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name">Functional</span>
|
||||
<span class="cmp-category__desc">Enable enhanced functionality and personalisation.</span>
|
||||
</div>
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name">Analytics</span>
|
||||
<span class="cmp-category__desc">Help us understand how visitors interact with the site.</span>
|
||||
</div>
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name">Marketing</span>
|
||||
<span class="cmp-category__desc">Used to deliver personalised advertisements.</span>
|
||||
</div>
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
<label class="cmp-category">
|
||||
<div class="cmp-category__info">
|
||||
<span class="cmp-category__name">Personalisation</span>
|
||||
<span class="cmp-category__desc">Enable content personalisation based on your profile.</span>
|
||||
</div>
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
${saveBtnHtml}
|
||||
</div>
|
||||
<div class="consentos-banner__actions">
|
||||
${rejectBtn}
|
||||
${manageBtn}
|
||||
${acceptBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePrefs() {
|
||||
var el = document.getElementById('cmp-prefs');
|
||||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ── Shared helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function buildButtonStyle(
|
||||
config: ButtonConfig | undefined,
|
||||
defaultStyle: 'filled' | 'outline',
|
||||
fallbackBg: string,
|
||||
fallbackText: string,
|
||||
fallbackBorder: string,
|
||||
radius: number,
|
||||
): string {
|
||||
const bg = config?.backgroundColour ?? fallbackBg;
|
||||
const color = config?.textColour ?? fallbackText;
|
||||
const style = config?.style ?? defaultStyle;
|
||||
const border = config?.borderColour
|
||||
? `1px solid ${config.borderColour}`
|
||||
: style === 'outline'
|
||||
? `1px solid ${config?.textColour ?? fallbackBorder}`
|
||||
: style === 'text'
|
||||
? 'none'
|
||||
: fallbackBorder === 'none'
|
||||
? 'none'
|
||||
: `1px solid ${fallbackBorder}`;
|
||||
const background = style === 'text' ? 'transparent' : style === 'outline' ? 'transparent' : bg;
|
||||
|
||||
return `background: ${background}; color: ${color}; border: ${border}; border-radius: ${radius}px;`;
|
||||
}
|
||||
|
||||
function buildBannerParts(
|
||||
bc: BannerConfig,
|
||||
primary: string,
|
||||
text: string,
|
||||
radius: number,
|
||||
privacyUrl: string | null,
|
||||
defaultButtonStyle: 'filled' | 'outline',
|
||||
) {
|
||||
const acceptStyle = buildButtonStyle(bc.acceptButton, defaultButtonStyle, primary, '#ffffff', 'none', radius);
|
||||
const rejectStyle = buildButtonStyle(bc.rejectButton, defaultButtonStyle, 'transparent', text, 'rgba(0,0,0,0.2)', radius);
|
||||
const manageStyle = buildButtonStyle(bc.manageButton, defaultButtonStyle, 'transparent', text, 'rgba(0,0,0,0.2)', radius);
|
||||
|
||||
// Resolve text content from config or defaults
|
||||
const titleText = bc.text?.title ?? DEFAULT_TITLE;
|
||||
const descriptionText = bc.text?.description ?? DEFAULT_DESCRIPTION;
|
||||
const acceptAllText = bc.text?.acceptAll ?? DEFAULT_ACCEPT_ALL;
|
||||
const rejectAllText = bc.text?.rejectAll ?? DEFAULT_REJECT_ALL;
|
||||
const managePreferencesText = bc.text?.managePreferences ?? DEFAULT_MANAGE_PREFERENCES;
|
||||
const savePreferencesText = bc.text?.savePreferences ?? DEFAULT_SAVE_PREFERENCES;
|
||||
|
||||
const acceptBtn = `<button class="cmp-btn" style="${acceptStyle}">${escapeHtml(acceptAllText)}</button>`;
|
||||
|
||||
const rejectBtn = bc.showRejectAll !== false
|
||||
? `<button class="cmp-btn" style="${rejectStyle}">${escapeHtml(rejectAllText)}</button>`
|
||||
: '';
|
||||
|
||||
const manageBtn = bc.showManagePreferences !== false
|
||||
? `<button class="cmp-btn" style="${manageStyle}" onclick="typeof togglePrefs==='function'&&togglePrefs()">${escapeHtml(managePreferencesText)}</button>`
|
||||
: '';
|
||||
|
||||
const closeBtn = bc.showCloseButton
|
||||
? `<button class="cmp-close" aria-label="Close">×</button>`
|
||||
: '';
|
||||
|
||||
const logoHtml = bc.showLogo && bc.logoUrl
|
||||
? `<img src="${escapeHtml(bc.logoUrl)}" alt="Logo" class="cmp-logo" />`
|
||||
: '';
|
||||
|
||||
const cookieCount = bc.showCookieCount
|
||||
? `<span class="cmp-cookie-count">12 cookies used on this site</span>`
|
||||
: '';
|
||||
|
||||
const privacyLink = privacyUrl
|
||||
? ` <a href="#" class="consentos-banner__link" onclick="return false">Privacy Policy</a>`
|
||||
: '';
|
||||
|
||||
return { rejectBtn, manageBtn, acceptBtn, closeBtn, logoHtml, cookieCount, privacyLink, titleText, descriptionText, savePreferencesText };
|
||||
}
|
||||
|
||||
function getPositionStyles(mode: DisplayMode, cornerPosition: CornerPosition, radius: number): string {
|
||||
switch (mode) {
|
||||
case 'top_banner':
|
||||
return 'position: fixed; top: 0; left: 0; right: 0; z-index: 2147483647;';
|
||||
case 'overlay':
|
||||
return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90%; max-width: 600px; border-radius: ${radius}px;`;
|
||||
case 'corner_popup': {
|
||||
const side = cornerPosition === 'left' ? 'left: 20px;' : 'right: 20px;';
|
||||
return `position: fixed; bottom: 20px; ${side} z-index: 2147483647; width: 380px; max-width: calc(100% - 40px); border-radius: ${radius}px;`;
|
||||
}
|
||||
case 'bottom_banner':
|
||||
default:
|
||||
return 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483647;';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
107
apps/admin-ui/src/components/CreateSiteModal.tsx
Normal file
107
apps/admin-ui/src/components/CreateSiteModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { listSiteGroups } from '../api/site-groups';
|
||||
import { createSite } from '../api/sites';
|
||||
import { trackFeatureUsage } from '../services/analytics';
|
||||
import { Modal } from './ui/modal.tsx';
|
||||
import { FormField } from './ui/form-field.tsx';
|
||||
import { Input } from './ui/input.tsx';
|
||||
import { Select } from './ui/select.tsx';
|
||||
import { Button } from './ui/button.tsx';
|
||||
import { Alert } from './ui/alert.tsx';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
defaultGroupId?: string;
|
||||
}
|
||||
|
||||
export default function CreateSiteModal({ onClose, defaultGroupId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [domain, setDomain] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [groupId, setGroupId] = useState(defaultGroupId ?? '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['site-groups'],
|
||||
queryFn: listSiteGroups,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createSite,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
|
||||
trackFeatureUsage('site', 'create');
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
setError('Failed to create site. Check the domain is unique.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
mutation.mutate({
|
||||
domain,
|
||||
display_name: name || domain,
|
||||
site_group_id: groupId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true} onClose={onClose} title="Add site">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
<FormField label="Domain">
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Display name (optional)">
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Website"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Site group (optional)">
|
||||
<Select
|
||||
id="group"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
>
|
||||
<option value="">No group</option>
|
||||
{groups?.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Creating...' : 'Create site'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
114
apps/admin-ui/src/components/ErrorBoundary.tsx
Normal file
114
apps/admin-ui/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Top-level React error boundary.
|
||||
*
|
||||
* Catches unhandled rendering errors so a single bad component cannot
|
||||
* take down the whole admin app. Falls back to a simple friendly
|
||||
* panel with a reload button. The error is logged to the console for
|
||||
* dev debugging; in production, wire this up to an error reporter.
|
||||
*/
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ErrorBoundary] Unhandled error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '28rem',
|
||||
background: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: '0 0 0.75rem', fontSize: '1.25rem', color: '#111' }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 1.5rem', color: '#555', lineHeight: 1.5 }}>
|
||||
An unexpected error occurred while rendering this page. The
|
||||
error has been logged. Reload to try again.
|
||||
</p>
|
||||
{this.state.error?.message && (
|
||||
<pre
|
||||
style={{
|
||||
background: '#f4f4f5',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.8rem',
|
||||
color: '#7f1d1d',
|
||||
overflow: 'auto',
|
||||
marginBottom: '1.5rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#111',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.9rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
141
apps/admin-ui/src/components/Layout.tsx
Normal file
141
apps/admin-ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { getNavItems } from '../extensions/registry';
|
||||
|
||||
const CORE_NAV_ITEMS = [
|
||||
{ path: '/sites', label: 'Sites', order: 10 },
|
||||
{ path: '/compliance', label: 'Compliance', order: 20 },
|
||||
{ path: '/settings', label: 'Settings', order: 90 },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const location = useLocation();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const NAV_ITEMS = useMemo(() => {
|
||||
const extensionItems = getNavItems().map((item) => ({
|
||||
path: item.path,
|
||||
label: item.label,
|
||||
order: item.order ?? 200,
|
||||
}));
|
||||
return [...CORE_NAV_ITEMS, ...extensionItems].sort(
|
||||
(a, b) => a.order - b.order,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Top nav */}
|
||||
<header className="sticky top-0 z-40 border-b border-border-subtle bg-card">
|
||||
<div className="flex h-14 items-center justify-between px-4 md:px-6">
|
||||
{/* Left: logo + desktop nav */}
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2 font-heading text-lg font-semibold text-foreground">
|
||||
<img src="/logo-mark.svg" alt="" width="24" height="24" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="text-primary">Consent</span>
|
||||
<span className="text-action">OS</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`relative pb-[17px] font-heading text-sm transition-colors ${
|
||||
isActive
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-text-tertiary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-copper" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right: user info + mobile hamburger */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{user?.full_name ?? user?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-text-tertiary hover:text-foreground"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="rounded-md p-1.5 text-text-tertiary hover:bg-mist md:hidden"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{mobileOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile slide-down nav */}
|
||||
{mobileOpen && (
|
||||
<nav className="border-t border-border-subtle bg-card px-4 py-3 md:hidden">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`block rounded-md px-3 py-2 text-sm font-medium ${
|
||||
isActive
|
||||
? 'bg-mist text-foreground'
|
||||
: 'text-text-tertiary hover:bg-mist hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="mt-3 border-t border-border-subtle pt-3">
|
||||
<p className="px-3 text-sm text-text-secondary">
|
||||
{user?.full_name ?? user?.email}
|
||||
</p>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-1 w-full rounded-md px-3 py-2 text-left text-sm text-text-tertiary hover:bg-mist hover:text-foreground"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="w-full px-6 py-10 md:px-12">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/admin-ui/src/components/ProtectedRoute.tsx
Normal file
13
apps/admin-ui/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
477
apps/admin-ui/src/components/SiteComplianceTab.tsx
Normal file
477
apps/admin-ui/src/components/SiteComplianceTab.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BannerConfig,
|
||||
ComplianceFramework,
|
||||
ComplianceIssue,
|
||||
ComplianceSeverity,
|
||||
ComplianceStatus,
|
||||
FrameworkResult,
|
||||
SiteConfig,
|
||||
} from '../types/api';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Card } from './ui/card';
|
||||
import { EmptyState } from './ui/empty-state';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
config: SiteConfig | null;
|
||||
}
|
||||
|
||||
interface ComplianceRule {
|
||||
ruleId: string;
|
||||
description: string;
|
||||
check: (ctx: SiteContext) => ComplianceIssue[];
|
||||
}
|
||||
|
||||
interface SiteContext {
|
||||
blockingMode: string;
|
||||
regionalModes: Record<string, string> | null;
|
||||
tcfEnabled: boolean;
|
||||
gcmEnabled: boolean;
|
||||
consentExpiryDays: number;
|
||||
privacyPolicyUrl: string | null;
|
||||
bannerConfig: BannerConfig | null;
|
||||
hasRejectButton: boolean;
|
||||
hasGranularChoices: boolean;
|
||||
hasCookieWall: boolean;
|
||||
preTicked: boolean;
|
||||
}
|
||||
|
||||
// ── Rule helpers ────────────────────────────────────────────────────
|
||||
|
||||
function issue(
|
||||
ruleId: string,
|
||||
severity: ComplianceSeverity,
|
||||
message: string,
|
||||
recommendation: string,
|
||||
): ComplianceIssue {
|
||||
return { rule_id: ruleId, severity, message, recommendation };
|
||||
}
|
||||
|
||||
// ── GDPR rules ──────────────────────────────────────────────────────
|
||||
|
||||
const GDPR_RULES: ComplianceRule[] = [
|
||||
{
|
||||
ruleId: 'gdpr_opt_in',
|
||||
description: 'Opt-in consent required',
|
||||
check: (ctx) =>
|
||||
ctx.blockingMode !== 'opt_in'
|
||||
? [issue('gdpr_opt_in', 'critical', 'GDPR requires opt-in consent before setting non-essential cookies.', "Set blocking mode to 'opt_in'.")]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'gdpr_reject_button',
|
||||
description: 'Reject as prominent as accept',
|
||||
check: (ctx) =>
|
||||
!ctx.hasRejectButton
|
||||
? [issue('gdpr_reject_button', 'critical', 'The reject option must be as prominent as the accept option.', "Add a clearly visible 'Reject all' button to the first layer.")]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'gdpr_granular',
|
||||
description: 'Granular category consent',
|
||||
check: (ctx) =>
|
||||
!ctx.hasGranularChoices
|
||||
? [issue('gdpr_granular', 'critical', 'Users must be able to consent to individual cookie categories.', 'Provide granular category toggles in the consent banner.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'gdpr_cookie_wall',
|
||||
description: 'No cookie walls',
|
||||
check: (ctx) =>
|
||||
ctx.hasCookieWall
|
||||
? [issue('gdpr_cookie_wall', 'critical', 'Cookie walls (blocking access unless consent is given) are not permitted.', 'Remove the cookie wall and allow access without consent.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'gdpr_pre_ticked',
|
||||
description: 'No pre-ticked boxes',
|
||||
check: (ctx) =>
|
||||
ctx.preTicked
|
||||
? [issue('gdpr_pre_ticked', 'critical', 'Pre-ticked consent boxes do not constitute valid consent.', 'Ensure all non-essential category checkboxes default to unchecked.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'gdpr_privacy_policy',
|
||||
description: 'Privacy policy link',
|
||||
check: (ctx) =>
|
||||
!ctx.privacyPolicyUrl
|
||||
? [issue('gdpr_privacy_policy', 'warning', 'A link to the privacy policy should be accessible from the banner.', 'Configure a privacy policy URL in the site settings.')]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── CNIL rules (GDPR + French-specific) ─────────────────────────────
|
||||
|
||||
const CNIL_EXTRA_RULES: ComplianceRule[] = [
|
||||
{
|
||||
ruleId: 'cnil_reconsent',
|
||||
description: 'Re-consent every 6 months',
|
||||
check: (ctx) =>
|
||||
ctx.consentExpiryDays > 182
|
||||
? [issue('cnil_reconsent', 'critical', 'CNIL requires re-consent at least every 6 months.', 'Set consent expiry to 182 days or fewer.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'cnil_cookie_lifetime',
|
||||
description: '13-month cookie lifetime',
|
||||
check: (ctx) =>
|
||||
ctx.consentExpiryDays > 395
|
||||
? [issue('cnil_cookie_lifetime', 'critical', 'CNIL limits consent cookie lifetime to 13 months.', 'Set consent expiry to 395 days or fewer.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'cnil_reject_first_layer',
|
||||
description: 'Reject on first layer',
|
||||
check: (ctx) =>
|
||||
!ctx.hasRejectButton
|
||||
? [issue('cnil_reject_first_layer', 'critical', "CNIL requires a 'Reject all' button on the first layer of the banner.", "Ensure the 'Reject all' button is visible on the first banner view.")]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
const CNIL_RULES: ComplianceRule[] = [...GDPR_RULES, ...CNIL_EXTRA_RULES];
|
||||
|
||||
// ── CCPA/CPRA rules ─────────────────────────────────────────────────
|
||||
|
||||
const CCPA_RULES: ComplianceRule[] = [
|
||||
{
|
||||
ruleId: 'ccpa_opt_out',
|
||||
description: 'Opt-out mechanism',
|
||||
check: (ctx) =>
|
||||
ctx.blockingMode === 'informational'
|
||||
? [issue('ccpa_opt_out', 'critical', 'CCPA requires at minimum an opt-out mechanism for data sale.', "Set blocking mode to 'opt_out' or 'opt_in'.")]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'ccpa_do_not_sell',
|
||||
description: 'Do Not Sell link',
|
||||
check: () => {
|
||||
// Banner config doesn't have a DNS toggle yet — always flag as advisory
|
||||
return [issue('ccpa_do_not_sell', 'warning', "CCPA requires a 'Do Not Sell My Personal Information' link on your site.", 'Add a Do Not Sell link to your website footer or privacy centre.')];
|
||||
},
|
||||
},
|
||||
{
|
||||
ruleId: 'ccpa_privacy_policy',
|
||||
description: 'Privacy policy required',
|
||||
check: (ctx) =>
|
||||
!ctx.privacyPolicyUrl
|
||||
? [issue('ccpa_privacy_policy', 'warning', 'A privacy policy is required under CCPA.', 'Configure a privacy policy URL in the site settings.')]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── ePrivacy rules ──────────────────────────────────────────────────
|
||||
|
||||
const EPRIVACY_RULES: ComplianceRule[] = [
|
||||
{
|
||||
ruleId: 'eprivacy_consent',
|
||||
description: 'Consent for non-essential',
|
||||
check: (ctx) =>
|
||||
ctx.blockingMode === 'informational'
|
||||
? [issue('eprivacy_consent', 'critical', 'ePrivacy Directive requires consent for non-essential cookies.', "Set blocking mode to 'opt_in' or 'opt_out'.")]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'eprivacy_necessary_exempt',
|
||||
description: 'Necessary cookies exempt',
|
||||
check: () => [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── LGPD rules (Brazil) ─────────────────────────────────────────────
|
||||
|
||||
const LGPD_RULES: ComplianceRule[] = [
|
||||
{
|
||||
ruleId: 'lgpd_consent_basis',
|
||||
description: 'Legal basis for processing',
|
||||
check: (ctx) =>
|
||||
ctx.blockingMode === 'informational'
|
||||
? [issue('lgpd_consent_basis', 'critical', 'LGPD requires a legal basis (consent or legitimate interest) for data processing.', "Set blocking mode to 'opt_in' or 'opt_out'.")]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'lgpd_data_controller',
|
||||
description: 'Identify data controller',
|
||||
check: (ctx) =>
|
||||
!ctx.privacyPolicyUrl
|
||||
? [issue('lgpd_data_controller', 'warning', 'LGPD requires identification of the data controller.', 'Link to a privacy policy that identifies the data controller.')]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
ruleId: 'lgpd_granular',
|
||||
description: 'Granular consent choices',
|
||||
check: (ctx) =>
|
||||
!ctx.hasGranularChoices
|
||||
? [issue('lgpd_granular', 'warning', 'LGPD recommends granular consent choices.', 'Provide individual category toggles in the consent banner.')]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Framework registry ──────────────────────────────────────────────
|
||||
|
||||
const FRAMEWORK_RULES: Record<ComplianceFramework, ComplianceRule[]> = {
|
||||
gdpr: GDPR_RULES,
|
||||
cnil: CNIL_RULES,
|
||||
ccpa: CCPA_RULES,
|
||||
eprivacy: EPRIVACY_RULES,
|
||||
lgpd: LGPD_RULES,
|
||||
};
|
||||
|
||||
const FRAMEWORKS: { id: ComplianceFramework; label: string }[] = [
|
||||
{ id: 'gdpr', label: 'GDPR' },
|
||||
{ id: 'cnil', label: 'CNIL' },
|
||||
{ id: 'ccpa', label: 'CCPA/CPRA' },
|
||||
{ id: 'eprivacy', label: 'ePrivacy' },
|
||||
{ id: 'lgpd', label: 'LGPD' },
|
||||
];
|
||||
|
||||
// ── Compliance engine ───────────────────────────────────────────────
|
||||
|
||||
function buildContext(config: SiteConfig | null): SiteContext {
|
||||
const bc = config?.banner_config ?? null;
|
||||
return {
|
||||
blockingMode: config?.blocking_mode ?? 'opt_in',
|
||||
regionalModes: config?.regional_modes ?? null,
|
||||
tcfEnabled: config?.tcf_enabled ?? false,
|
||||
gcmEnabled: config?.gcm_enabled ?? true,
|
||||
consentExpiryDays: config?.consent_expiry_days ?? 365,
|
||||
privacyPolicyUrl: config?.privacy_policy_url ?? null,
|
||||
bannerConfig: bc,
|
||||
hasRejectButton: bc?.showRejectAll !== false,
|
||||
hasGranularChoices: bc?.showManagePreferences !== false,
|
||||
hasCookieWall: false, // Not a config option — always false
|
||||
preTicked: false, // Banner never pre-ticks — always false
|
||||
};
|
||||
}
|
||||
|
||||
function runFrameworkCheck(framework: ComplianceFramework, ctx: SiteContext): FrameworkResult {
|
||||
const rules = FRAMEWORK_RULES[framework];
|
||||
const allIssues: ComplianceIssue[] = [];
|
||||
let rulesPassed = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const issues = rule.check(ctx);
|
||||
if (issues.length > 0) {
|
||||
allIssues.push(...issues);
|
||||
} else {
|
||||
rulesPassed++;
|
||||
}
|
||||
}
|
||||
|
||||
const rulesChecked = rules.length;
|
||||
const score = calculateScore(allIssues);
|
||||
const hasCritical = allIssues.some((i) => i.severity === 'critical');
|
||||
const status: ComplianceStatus = hasCritical ? 'non_compliant' : score >= 100 ? 'compliant' : 'partial';
|
||||
|
||||
return { framework, score, status, issues: allIssues, rules_checked: rulesChecked, rules_passed: rulesPassed };
|
||||
}
|
||||
|
||||
function calculateScore(issues: ComplianceIssue[]): number {
|
||||
let deductions = 0;
|
||||
for (const i of issues) {
|
||||
if (i.severity === 'critical') deductions += 20;
|
||||
else if (i.severity === 'warning') deductions += 5;
|
||||
}
|
||||
return Math.max(0, 100 - deductions);
|
||||
}
|
||||
|
||||
// ── UI Components ───────────────────────────────────────────────────
|
||||
|
||||
function ComplianceStatusBadge({ status }: { status: ComplianceStatus }) {
|
||||
const variantMap: Record<ComplianceStatus, 'success' | 'warning' | 'error'> = {
|
||||
compliant: 'success',
|
||||
partial: 'warning',
|
||||
non_compliant: 'error',
|
||||
};
|
||||
const labels: Record<ComplianceStatus, string> = {
|
||||
compliant: 'Compliant',
|
||||
partial: 'Partial',
|
||||
non_compliant: 'Non-compliant',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variantMap[status]} className="text-xs font-semibold">
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityIcon({ severity }: { severity: ComplianceSeverity }) {
|
||||
const icons: Record<ComplianceSeverity, { symbol: string; colour: string }> = {
|
||||
critical: { symbol: '!', colour: 'bg-status-error-fg text-white' },
|
||||
warning: { symbol: '!', colour: 'bg-status-warning-fg text-white' },
|
||||
info: { symbol: 'i', colour: 'bg-status-info-bg text-status-info-fg' },
|
||||
};
|
||||
const { symbol, colour } = icons[severity];
|
||||
|
||||
return (
|
||||
<span className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold ${colour}`}>
|
||||
{symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const colour = score >= 80 ? 'text-status-success-fg' : score >= 50 ? 'text-status-warning-fg' : 'text-status-error-fg';
|
||||
|
||||
return (
|
||||
<div className={`text-3xl font-bold ${colour}`}>
|
||||
{score}
|
||||
<span className="text-base font-normal text-text-tertiary">/100</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueRow({ issueData }: { issueData: ComplianceIssue }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-0">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-mist"
|
||||
>
|
||||
<SeverityIcon severity={issueData.severity} />
|
||||
<span className="flex-1 text-sm text-foreground">{issueData.message}</span>
|
||||
<span className="text-xs text-text-tertiary">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="bg-background px-4 pb-3 pl-12">
|
||||
<p className="text-sm text-text-secondary">{issueData.recommendation}</p>
|
||||
<p className="mt-1 font-mono text-xs text-text-tertiary">{issueData.rule_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrameworkCard({ result }: { result: FrameworkResult }) {
|
||||
const [expanded, setExpanded] = useState(result.issues.length > 0);
|
||||
const label = FRAMEWORKS.find((f) => f.id === result.framework)?.label ?? result.framework;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between px-4 py-4 text-left hover:bg-mist sm:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<ScoreRing score={result.score} />
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground sm:text-base">{label}</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{result.rules_passed}/{result.rules_checked} rules passed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<ComplianceStatusBadge status={result.status} />
|
||||
{result.issues.length > 0 && (
|
||||
<span className="hidden text-xs text-text-tertiary sm:inline">
|
||||
{result.issues.length} issue{result.issues.length !== 1 ? 's' : ''} {expanded ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && result.issues.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
{result.issues.map((issueData) => (
|
||||
<IssueRow key={issueData.rule_id} issueData={issueData} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────
|
||||
|
||||
export default function SiteComplianceTab({ siteId: _siteId, config }: Props) {
|
||||
const [selectedFrameworks, setSelectedFrameworks] = useState<Set<ComplianceFramework>>(
|
||||
new Set(FRAMEWORKS.map((f) => f.id)),
|
||||
);
|
||||
|
||||
// Run compliance checks purely on the frontend
|
||||
const { results, overallScore } = useMemo(() => {
|
||||
const ctx = buildContext(config);
|
||||
const frameworkResults = [...selectedFrameworks].map((fw) => runFrameworkCheck(fw, ctx));
|
||||
const overall = frameworkResults.length > 0
|
||||
? Math.round(frameworkResults.reduce((sum, r) => sum + r.score, 0) / frameworkResults.length)
|
||||
: 100;
|
||||
return { results: frameworkResults, overallScore: overall };
|
||||
}, [config, selectedFrameworks]);
|
||||
|
||||
const toggleFramework = (id: ComplianceFramework) => {
|
||||
setSelectedFrameworks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
if (next.size > 1) next.delete(id); // Keep at least one selected
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<EmptyState message="No site configuration found. Configure your site first." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Compliance Checker</h2>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Your site configuration is checked against regulatory frameworks in real time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Framework selector */}
|
||||
<div className="mb-4 flex flex-wrap gap-2 sm:mb-6">
|
||||
{FRAMEWORKS.map((fw) => (
|
||||
<button
|
||||
key={fw.id}
|
||||
onClick={() => toggleFramework(fw.id)}
|
||||
className={`rounded-full border px-3 py-1 text-sm font-medium transition ${
|
||||
selectedFrameworks.has(fw.id)
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-border bg-card text-text-secondary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{fw.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overall score */}
|
||||
<Card className="mb-4 p-4 sm:mb-6 sm:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-medium text-text-secondary">Overall Compliance Score</h3>
|
||||
<div className="mt-1">
|
||||
<ScoreRing score={overallScore} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-text-secondary">
|
||||
{results.length} framework{results.length !== 1 ? 's' : ''} checked
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Per-framework results */}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{results.map((result) => (
|
||||
<FrameworkCard key={result.framework} result={result} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
467
apps/admin-ui/src/components/SiteConfigTab.tsx
Normal file
467
apps/admin-ui/src/components/SiteConfigTab.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { getConfigInheritance, updateSiteConfig } from '../api/sites';
|
||||
import { trackConfigChange } from '../services/analytics';
|
||||
import type { ConfigInheritanceResponse, ConfigSource, SiteConfig } from '../types/api';
|
||||
import { Alert } from './ui/alert';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { FormField } from './ui/form-field';
|
||||
import { Input } from './ui/input';
|
||||
import { Select } from './ui/select';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
config: SiteConfig | null;
|
||||
}
|
||||
|
||||
const GPP_SECTIONS = [
|
||||
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
|
||||
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
|
||||
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
|
||||
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
|
||||
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
|
||||
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
|
||||
];
|
||||
|
||||
const GPC_JURISDICTIONS = [
|
||||
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
|
||||
{ value: 'US-CO', label: 'Colorado (CPA)' },
|
||||
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
|
||||
{ value: 'US-TX', label: 'Texas (TDPSA)' },
|
||||
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<ConfigSource, string> = {
|
||||
system: 'System default',
|
||||
org: 'Organisation default',
|
||||
group: 'Group default',
|
||||
site: 'Site override',
|
||||
};
|
||||
|
||||
const SOURCE_COLOURS: Record<ConfigSource, string> = {
|
||||
system: 'bg-gray-100 text-gray-600',
|
||||
org: 'bg-blue-50 text-blue-700',
|
||||
group: 'bg-purple-50 text-purple-700',
|
||||
site: 'bg-green-50 text-green-700',
|
||||
};
|
||||
|
||||
function SourceBadge({ source, field }: { source: ConfigSource; field: string }) {
|
||||
if (source === 'site') return null;
|
||||
return (
|
||||
<span
|
||||
className={`ml-2 inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${SOURCE_COLOURS[source]}`}
|
||||
title={`The value for "${field}" is inherited from ${SOURCE_LABELS[source].toLowerCase()}`}
|
||||
>
|
||||
{SOURCE_LABELS[source]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button to reset a field to its inherited default.
|
||||
* Only shown when the field is overridden at site level.
|
||||
*/
|
||||
function ResetButton({
|
||||
field,
|
||||
inheritance,
|
||||
onReset,
|
||||
}: {
|
||||
field: string;
|
||||
inheritance: ConfigInheritanceResponse | undefined;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const source = inheritance?.fields[field]?.source;
|
||||
if (source !== 'site') return null;
|
||||
|
||||
const parentSource = getParentSource(field, inheritance);
|
||||
const label = parentSource
|
||||
? `Reset to ${SOURCE_LABELS[parentSource].toLowerCase()}`
|
||||
: 'Reset to default';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="ml-2 text-[10px] font-medium text-primary hover:text-primary/80 hover:underline"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Determine which parent level would provide the value if site override is removed. */
|
||||
function getParentSource(
|
||||
field: string,
|
||||
inheritance: ConfigInheritanceResponse | undefined,
|
||||
): ConfigSource | null {
|
||||
if (!inheritance) return null;
|
||||
const info = inheritance.fields[field];
|
||||
if (!info) return null;
|
||||
if (info.group_value != null) return 'group';
|
||||
if (info.org_value != null) return 'org';
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export default function SiteConfigTab({ siteId, config }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [blockingMode, setBlockingMode] = useState<string>(config?.blocking_mode ?? 'opt_in');
|
||||
const [tcfEnabled, setTcfEnabled] = useState(config?.tcf_enabled ?? false);
|
||||
const [gcmEnabled, setGcmEnabled] = useState(config?.gcm_enabled ?? true);
|
||||
const [shopifyEnabled, setShopifyEnabled] = useState(config?.shopify_privacy_enabled ?? false);
|
||||
const [consentExpiry, setConsentExpiry] = useState(config?.consent_expiry_days ?? 365);
|
||||
const [privacyUrl, setPrivacyUrl] = useState(config?.privacy_policy_url ?? '');
|
||||
const [termsUrl, setTermsUrl] = useState(config?.terms_url ?? '');
|
||||
|
||||
// GPP state
|
||||
const [gppEnabled, setGppEnabled] = useState(config?.gpp_enabled ?? true);
|
||||
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>(
|
||||
config?.gpp_supported_apis ?? ['usnat'],
|
||||
);
|
||||
|
||||
// GPC state
|
||||
const [gpcEnabled, setGpcEnabled] = useState(config?.gpc_enabled ?? true);
|
||||
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>(
|
||||
config?.gpc_jurisdictions ?? ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
|
||||
);
|
||||
const [gpcGlobalHonour, setGpcGlobalHonour] = useState(config?.gpc_global_honour ?? false);
|
||||
|
||||
// Track which fields should be sent as null (reset to default)
|
||||
const [resetFields, setResetFields] = useState<Set<string>>(new Set());
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const { data: inheritance } = useQuery({
|
||||
queryKey: ['sites', siteId, 'inheritance'],
|
||||
queryFn: () => getConfigInheritance(siteId),
|
||||
enabled: !!siteId,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => updateSiteConfig(siteId, body as Partial<SiteConfig>),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'inheritance'] });
|
||||
trackConfigChange('site_config', { site_id: siteId });
|
||||
setResetFields(new Set());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const markReset = (field: string) => {
|
||||
setResetFields((prev) => new Set([...prev, field]));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
blocking_mode: blockingMode,
|
||||
tcf_enabled: tcfEnabled,
|
||||
gcm_enabled: gcmEnabled,
|
||||
shopify_privacy_enabled: shopifyEnabled,
|
||||
consent_expiry_days: consentExpiry,
|
||||
privacy_policy_url: privacyUrl || null,
|
||||
terms_url: termsUrl || null,
|
||||
gpp_enabled: gppEnabled,
|
||||
gpp_supported_apis: gppEnabled ? gppSupportedApis : null,
|
||||
gpc_enabled: gpcEnabled,
|
||||
gpc_jurisdictions: gpcEnabled ? gpcJurisdictions : null,
|
||||
gpc_global_honour: gpcGlobalHonour,
|
||||
};
|
||||
|
||||
// Override any fields marked for reset with null
|
||||
for (const field of resetFields) {
|
||||
body[field] = null;
|
||||
}
|
||||
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const toggleGppSection = (api: string) => {
|
||||
setGppSupportedApis((prev) =>
|
||||
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleGpcJurisdiction = (code: string) => {
|
||||
setGpcJurisdictions((prev) =>
|
||||
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
|
||||
);
|
||||
};
|
||||
|
||||
const getSource = (field: string): ConfigSource => {
|
||||
return inheritance?.fields[field]?.source ?? 'site';
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Inheritance info banner */}
|
||||
{inheritance && (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface p-4">
|
||||
<p className="text-xs text-text-secondary">
|
||||
<strong>Configuration cascade:</strong> System defaults
|
||||
{' \u2192 '}Organisation defaults
|
||||
{inheritance.site_group_id && <>{' \u2192 '}Group defaults</>}
|
||||
{' \u2192 '}<span className="font-semibold">Site config</span>
|
||||
{' \u2192 '}Regional overrides.
|
||||
Fields with a coloured badge are inherited from a higher level.
|
||||
Click “Reset” to remove a site-level override and inherit the parent value.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Consent settings</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<FormField label="Blocking mode">
|
||||
<Select
|
||||
value={blockingMode}
|
||||
onChange={(e) => setBlockingMode(e.target.value)}
|
||||
>
|
||||
<option value="opt_in">Opt-in (GDPR)</option>
|
||||
<option value="opt_out">Opt-out (CCPA)</option>
|
||||
<option value="informational">Informational only</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<SourceBadge source={getSource('blocking_mode')} field="blocking mode" />
|
||||
<ResetButton field="blocking_mode" inheritance={inheritance} onReset={() => markReset('blocking_mode')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<FormField label="Consent expiry (days)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={730}
|
||||
value={consentExpiry}
|
||||
onChange={(e) => setConsentExpiry(Number(e.target.value))}
|
||||
/>
|
||||
</FormField>
|
||||
<SourceBadge source={getSource('consent_expiry_days')} field="consent expiry" />
|
||||
<ResetButton field="consent_expiry_days" inheritance={inheritance} onReset={() => markReset('consent_expiry_days')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<FormField label="Privacy policy URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={privacyUrl}
|
||||
onChange={(e) => setPrivacyUrl(e.target.value)}
|
||||
placeholder="https://example.com/privacy"
|
||||
/>
|
||||
</FormField>
|
||||
<SourceBadge source={getSource('privacy_policy_url')} field="privacy policy URL" />
|
||||
<ResetButton field="privacy_policy_url" inheritance={inheritance} onReset={() => { setPrivacyUrl(''); markReset('privacy_policy_url'); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<FormField label="Terms & conditions URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={termsUrl}
|
||||
onChange={(e) => setTermsUrl(e.target.value)}
|
||||
placeholder="https://example.com/terms"
|
||||
/>
|
||||
</FormField>
|
||||
<SourceBadge source={getSource('terms_url')} field="terms URL" />
|
||||
<ResetButton field="terms_url" inheritance={inheritance} onReset={() => { setTermsUrl(''); markReset('terms_url'); }} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Use <code className="rounded bg-surface px-1">{'{{privacy_policy}}'}</code> and{' '}
|
||||
<code className="rounded bg-surface px-1">{'{{terms}}'}</code> in your banner
|
||||
description with markdown links, e.g.{' '}
|
||||
<code className="rounded bg-surface px-1">{'[Privacy Policy]({{privacy_policy}})'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Standards & integrations</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tcfEnabled}
|
||||
onChange={(e) => setTcfEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-text-secondary">IAB TCF v2.2</span>
|
||||
<SourceBadge source={getSource('tcf_enabled')} field="TCF" />
|
||||
<ResetButton field="tcf_enabled" inheritance={inheritance} onReset={() => markReset('tcf_enabled')} />
|
||||
</div>
|
||||
</label>
|
||||
<p className="ml-7 text-xs text-text-secondary">Enable Transparency and Consent Framework</p>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gcmEnabled}
|
||||
onChange={(e) => setGcmEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-text-secondary">Google Consent Mode v2</span>
|
||||
<SourceBadge source={getSource('gcm_enabled')} field="GCM" />
|
||||
<ResetButton field="gcm_enabled" inheritance={inheritance} onReset={() => markReset('gcm_enabled')} />
|
||||
</div>
|
||||
</label>
|
||||
<p className="ml-7 text-xs text-text-secondary">Automatically set gtag consent signals</p>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shopifyEnabled}
|
||||
onChange={(e) => setShopifyEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-text-secondary">Shopify Customer Privacy API</span>
|
||||
<SourceBadge source={getSource('shopify_privacy_enabled')} field="Shopify Privacy" />
|
||||
<ResetButton field="shopify_privacy_enabled" inheritance={inheritance} onReset={() => markReset('shopify_privacy_enabled')} />
|
||||
</div>
|
||||
</label>
|
||||
<p className="ml-7 text-xs text-text-secondary">
|
||||
Bridge consent decisions to Shopify's <code>setTrackingConsent()</code> API.
|
||||
Enable this for Shopify-hosted stores.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Privacy Signals — GPP */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">IAB Global Privacy Platform (GPP)</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPP provides a standardised consent string format for US state privacy laws.
|
||||
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP strings
|
||||
for the selected sections.
|
||||
</p>
|
||||
|
||||
<label className="mb-4 flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gppEnabled}
|
||||
onChange={(e) => setGppEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-text-secondary">Enable GPP</span>
|
||||
<ResetButton field="gpp_enabled" inheritance={inheritance} onReset={() => markReset('gpp_enabled')} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{gppEnabled && (
|
||||
<div className="ml-7 space-y-2">
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
|
||||
{GPP_SECTIONS.map((section) => (
|
||||
<label key={section.value} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gppSupportedApis.includes(section.value)}
|
||||
onChange={() => toggleGppSection(section.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{section.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Privacy Signals — GPC */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Global Privacy Control (GPC)</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPC is a browser signal indicating a user's intent to opt out of the sale or
|
||||
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
|
||||
legally require businesses to honour this signal.
|
||||
</p>
|
||||
|
||||
<label className="mb-4 flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpcEnabled}
|
||||
onChange={(e) => setGpcEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-text-secondary">Detect GPC signal</span>
|
||||
<ResetButton field="gpc_enabled" inheritance={inheritance} onReset={() => markReset('gpc_enabled')} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{gpcEnabled && (
|
||||
<div className="ml-7 space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpcGlobalHonour}
|
||||
onChange={(e) => setGpcGlobalHonour(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-secondary">Honour globally</span>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Apply GPC opt-out for all visitors regardless of jurisdiction
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!gpcGlobalHonour && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">
|
||||
Jurisdictions where GPC is legally required
|
||||
</p>
|
||||
{GPC_JURISDICTIONS.map((j) => (
|
||||
<label key={j.value} className="flex items-center gap-2 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpcJurisdictions.includes(j.value)}
|
||||
onChange={() => toggleGpcJurisdiction(j.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{j.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save configuration'}
|
||||
</Button>
|
||||
{resetFields.size > 0 && (
|
||||
<span className="text-xs text-text-secondary">
|
||||
{resetFields.size} field{resetFields.size > 1 ? 's' : ''} will be reset to default
|
||||
</span>
|
||||
)}
|
||||
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
|
||||
{mutation.isError && (
|
||||
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
150
apps/admin-ui/src/components/SiteCookiesTab.tsx
Normal file
150
apps/admin-ui/src/components/SiteCookiesTab.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { getCookieSummary, listCategories, listCookies, updateCookie } from '../api/cookies';
|
||||
import type { Cookie, CookieCategory } from '../types/api';
|
||||
import { Badge } from './ui/badge';
|
||||
import { EmptyState } from './ui/empty-state';
|
||||
import { LoadingState } from './ui/loading-state';
|
||||
import { MetricCard } from './ui/metric-card';
|
||||
import { Select } from './ui/select';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export default function SiteCookiesTab({ siteId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: cookies, isLoading } = useQuery({
|
||||
queryKey: ['cookies', siteId],
|
||||
queryFn: () => listCookies(siteId),
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['cookie-categories'],
|
||||
queryFn: listCategories,
|
||||
});
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['cookies', siteId, 'summary'],
|
||||
queryFn: () => getCookieSummary(siteId),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ cookieId, body }: { cookieId: string; body: Partial<Cookie> }) =>
|
||||
updateCookie(siteId, cookieId, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cookies', siteId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cookies', siteId, 'summary'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading cookies..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<MetricCard label="Total" value={summary.total} />
|
||||
<MetricCard label="Pending review" value={summary.by_status?.pending ?? 0} />
|
||||
<MetricCard label="Approved" value={summary.by_status?.approved ?? 0} />
|
||||
<MetricCard label="Uncategorised" value={summary.uncategorised} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cookies table */}
|
||||
{cookies && cookies.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-background text-left text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
<th className="px-4 py-3">Name</th>
|
||||
<th className="px-4 py-3">Domain</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{cookies.map((cookie: Cookie) => (
|
||||
<tr key={cookie.id} className="transition hover:bg-mist">
|
||||
<td className="px-4 py-3 text-sm font-mono text-foreground">{cookie.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{cookie.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Select
|
||||
value={cookie.category_id ?? ''}
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
cookieId: cookie.id,
|
||||
body: { category_id: e.target.value || null },
|
||||
})
|
||||
}
|
||||
className="h-auto w-auto px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Uncategorised</option>
|
||||
{(categories ?? []).map((cat: CookieCategory) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={
|
||||
cookie.review_status === 'approved'
|
||||
? 'success'
|
||||
: cookie.review_status === 'rejected'
|
||||
? 'error'
|
||||
: cookie.review_status === 'pending'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{cookie.review_status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{cookie.review_status !== 'approved' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
cookieId: cookie.id,
|
||||
body: { review_status: 'approved' },
|
||||
})
|
||||
}
|
||||
className="rounded bg-status-success-bg px-2 py-1 text-xs font-medium text-status-success-fg hover:opacity-80"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
{cookie.review_status !== 'rejected' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
cookieId: cookie.id,
|
||||
body: { review_status: 'rejected' },
|
||||
})
|
||||
}
|
||||
className="rounded bg-status-error-bg px-2 py-1 text-xs font-medium text-status-error-fg hover:opacity-80"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="No cookies discovered yet. Run a scan or wait for client-side reporting." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/admin-ui/src/components/SiteOverviewTab.tsx
Normal file
75
apps/admin-ui/src/components/SiteOverviewTab.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Card } from './ui/card';
|
||||
import { MetricCard } from './ui/metric-card';
|
||||
import type { Site, SiteConfig } from '../types/api';
|
||||
|
||||
interface Props {
|
||||
site: Site;
|
||||
config: SiteConfig | null;
|
||||
}
|
||||
|
||||
export default function SiteOverviewTab({ site, config }: Props) {
|
||||
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}" async></script>`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<MetricCard
|
||||
label="Status"
|
||||
value={site.is_active ? 'Active' : 'Inactive'}
|
||||
className={site.is_active ? 'text-status-success-fg' : ''}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Blocking mode"
|
||||
value={config?.blocking_mode?.replace('_', ' ') ?? 'Not configured'}
|
||||
className="capitalize"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Consent expiry"
|
||||
value={`${config?.consent_expiry_days ?? 365} days`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Integration snippet */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">Integration snippet</h3>
|
||||
<p className="mb-3 text-sm text-text-secondary">
|
||||
Add this script tag to the {'<head>'} of your website, before any other scripts.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
|
||||
{scriptTag}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(scriptTag)}
|
||||
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Features</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FeatureItem label="TCF v2.2" enabled={config?.tcf_enabled ?? false} />
|
||||
<FeatureItem label="Google Consent Mode" enabled={config?.gcm_enabled ?? false} />
|
||||
<FeatureItem label="Auto-blocking" enabled={config?.blocking_mode !== 'informational'} />
|
||||
<FeatureItem label="Custom banner" enabled={!!config?.banner_config} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureItem({ label, enabled }: { label: string; enabled: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${enabled ? 'bg-status-success-fg' : 'bg-text-tertiary'}`}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/admin-ui/src/components/SiteScannerTab.tsx
Normal file
289
apps/admin-ui/src/components/SiteScannerTab.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
import { getScan, getScanDiff, listScans, triggerScan } from '../api/scanner';
|
||||
import { trackFeatureUsage } from '../services/analytics';
|
||||
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult } from '../types/api';
|
||||
import { Alert } from './ui/alert';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { LoadingState } from './ui/loading-state';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
function statusVariant(status: string): 'warning' | 'info' | 'success' | 'error' | 'neutral' {
|
||||
const map: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
|
||||
pending: 'warning',
|
||||
running: 'info',
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
};
|
||||
return map[status] ?? 'neutral';
|
||||
}
|
||||
|
||||
function diffVariant(status: string): 'success' | 'error' | 'warning' | 'neutral' {
|
||||
const map: Record<string, 'success' | 'error' | 'warning'> = {
|
||||
new: 'success',
|
||||
removed: 'error',
|
||||
changed: 'warning',
|
||||
};
|
||||
return map[status] ?? 'neutral';
|
||||
}
|
||||
|
||||
function DiffSection({ title, items }: { title: string; items: CookieDiffItem[] }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-text-secondary">{title} ({items.length})</h4>
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Type</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={`${item.name}-${item.domain}-${idx}`}>
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.name}</td>
|
||||
<td className="px-3 py-2 text-text-secondary">{item.domain}</td>
|
||||
<td className="px-3 py-2 text-text-secondary">{item.storage_type}</td>
|
||||
<td className="px-3 py-2"><Badge variant={diffVariant(item.diff_status)}>{item.diff_status}</Badge></td>
|
||||
<td className="px-3 py-2 text-text-secondary">{item.details ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanDiffView({ scanId }: { scanId: string }) {
|
||||
const { data: diff, isLoading } = useQuery<ScanDiff>({
|
||||
queryKey: ['scans', scanId, 'diff'],
|
||||
queryFn: () => getScanDiff(scanId),
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState message="Loading diff..." className="py-2" />;
|
||||
if (!diff) return null;
|
||||
|
||||
const hasChanges = diff.total_new + diff.total_removed + diff.total_changed > 0;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-md border border-border bg-background p-4">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
Scan Diff
|
||||
{diff.previous_scan_id ? '' : ' (first scan — no comparison available)'}
|
||||
</h3>
|
||||
{hasChanges ? (
|
||||
<>
|
||||
<DiffSection title="New Cookies" items={diff.new_cookies} />
|
||||
<DiffSection title="Removed Cookies" items={diff.removed_cookies} />
|
||||
<DiffSection title="Changed Cookies" items={diff.changed_cookies} />
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-text-secondary">No changes detected.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InitiatorChain({ chain }: { chain: string[] }) {
|
||||
if (chain.length === 0) return <span className="text-text-tertiary">—</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||
{chain.map((url, idx) => {
|
||||
// Show just the pathname for brevity
|
||||
let label: string;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
label = parsed.pathname.length > 40
|
||||
? '…' + parsed.pathname.slice(-38)
|
||||
: parsed.pathname;
|
||||
} catch {
|
||||
label = url.length > 40 ? '…' + url.slice(-38) : url;
|
||||
}
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
{idx > 0 && <span className="text-text-tertiary">→</span>}
|
||||
<span
|
||||
className="rounded bg-mist px-1.5 py-0.5 font-mono text-text-secondary"
|
||||
title={url}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanResultsView({ scanId }: { scanId: string }) {
|
||||
const { data: detail, isLoading } = useQuery<ScanJobDetail>({
|
||||
queryKey: ['scans', scanId, 'detail'],
|
||||
queryFn: () => getScan(scanId),
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingState message="Loading results..." className="py-2" />;
|
||||
if (!detail || detail.results.length === 0) {
|
||||
return <p className="py-2 text-sm text-text-secondary">No results recorded.</p>;
|
||||
}
|
||||
|
||||
// Only show results that have an initiator chain
|
||||
const withChain = detail.results.filter(
|
||||
(r: ScanResult) => r.initiator_chain && r.initiator_chain.length > 1,
|
||||
);
|
||||
|
||||
if (withChain.length === 0) {
|
||||
return <p className="py-2 text-sm text-text-secondary">No initiator chains detected in this scan.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-text-secondary">
|
||||
Initiator Chains ({withChain.length} cookies)
|
||||
</h4>
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Cookie</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Chain</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{withChain.map((r: ScanResult) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2 font-mono text-xs">{r.cookie_name}</td>
|
||||
<td className="px-3 py-2 text-text-secondary">{r.cookie_domain}</td>
|
||||
<td className="px-3 py-2">
|
||||
<InitiatorChain chain={r.initiator_chain!} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SiteScannerTab({ siteId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedScanId, setExpandedScanId] = useState<string | null>(null);
|
||||
|
||||
const { data: scans, isLoading } = useQuery<ScanJob[]>({
|
||||
queryKey: ['scans', siteId],
|
||||
queryFn: () => listScans(siteId),
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: () => triggerScan(siteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scans', siteId] });
|
||||
trackFeatureUsage('scan', 'trigger', { site_id: siteId });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading scans..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header with trigger button */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">Cookie Scans</h2>
|
||||
<Button
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending}
|
||||
>
|
||||
{triggerMutation.isPending ? 'Triggering...' : 'Trigger Scan'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{triggerMutation.isError && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
Failed to trigger scan. A scan may already be in progress.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Scan history */}
|
||||
{!scans || scans.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text-secondary">
|
||||
No scans yet. Trigger a scan to discover cookies on your site.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-background">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Trigger</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Pages</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Cookies Found</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Started</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Completed</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{scans.map((scan) => (
|
||||
<Fragment key={scan.id}>
|
||||
<tr className="hover:bg-mist">
|
||||
<td className="px-4 py-3"><Badge variant={statusVariant(scan.status)}>{scan.status}</Badge></td>
|
||||
<td className="px-4 py-3 text-text-secondary">{scan.trigger}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{scan.pages_scanned}{scan.pages_total ? ` / ${scan.pages_total}` : ''}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{scan.cookies_found}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{scan.started_at ? new Date(scan.started_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{scan.completed_at ? new Date(scan.completed_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{scan.status === 'completed' && (
|
||||
<button
|
||||
onClick={() => setExpandedScanId(expandedScanId === scan.id ? null : scan.id)}
|
||||
className="text-copper hover:text-copper/80 text-xs font-medium"
|
||||
>
|
||||
{expandedScanId === scan.id ? 'Hide Diff' : 'View Diff'}
|
||||
</button>
|
||||
)}
|
||||
{scan.status === 'failed' && scan.error_message && (
|
||||
<span className="text-xs text-status-error-fg" title={scan.error_message}>
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedScanId === scan.id && (
|
||||
<tr key={`${scan.id}-diff`}>
|
||||
<td colSpan={7} className="px-4 py-2">
|
||||
<ScanDiffView scanId={scan.id} />
|
||||
<ScanResultsView scanId={scan.id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
397
apps/admin-ui/src/components/SiteTranslationsTab.tsx
Normal file
397
apps/admin-ui/src/components/SiteTranslationsTab.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import {
|
||||
createTranslation,
|
||||
deleteTranslation,
|
||||
listTranslations,
|
||||
updateTranslation,
|
||||
} from '../api/translations';
|
||||
import type { Translation } from '../types/api';
|
||||
import { Alert } from './ui/alert';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { EmptyState } from './ui/empty-state';
|
||||
import { FormField } from './ui/form-field';
|
||||
import { Input } from './ui/input';
|
||||
import { LoadingState } from './ui/loading-state';
|
||||
import { Modal } from './ui/modal';
|
||||
import { Select } from './ui/select';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
/** The translation keys that the banner script expects. */
|
||||
const TRANSLATION_KEYS = [
|
||||
{ key: 'title', label: 'Banner title', placeholder: 'We use cookies' },
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Banner description',
|
||||
placeholder: 'We use cookies and similar technologies...',
|
||||
multiline: true,
|
||||
},
|
||||
{ key: 'acceptAll', label: 'Accept all button', placeholder: 'Accept all' },
|
||||
{ key: 'rejectAll', label: 'Reject all button', placeholder: 'Reject all' },
|
||||
{
|
||||
key: 'managePreferences',
|
||||
label: 'Manage preferences button',
|
||||
placeholder: 'Manage preferences',
|
||||
},
|
||||
{ key: 'savePreferences', label: 'Save preferences button', placeholder: 'Save preferences' },
|
||||
{ key: 'privacyPolicyLink', label: 'Privacy policy link text', placeholder: 'Privacy Policy' },
|
||||
{ key: 'closeLabel', label: 'Close button label', placeholder: 'Close' },
|
||||
{ key: 'categoryNecessary', label: 'Necessary category', placeholder: 'Necessary' },
|
||||
{
|
||||
key: 'categoryNecessaryDesc',
|
||||
label: 'Necessary description',
|
||||
placeholder: 'Essential for the website to function.',
|
||||
},
|
||||
{ key: 'categoryFunctional', label: 'Functional category', placeholder: 'Functional' },
|
||||
{
|
||||
key: 'categoryFunctionalDesc',
|
||||
label: 'Functional description',
|
||||
placeholder: 'Enable enhanced functionality.',
|
||||
},
|
||||
{ key: 'categoryAnalytics', label: 'Analytics category', placeholder: 'Analytics' },
|
||||
{
|
||||
key: 'categoryAnalyticsDesc',
|
||||
label: 'Analytics description',
|
||||
placeholder: 'Help us understand how visitors interact.',
|
||||
},
|
||||
{ key: 'categoryMarketing', label: 'Marketing category', placeholder: 'Marketing' },
|
||||
{
|
||||
key: 'categoryMarketingDesc',
|
||||
label: 'Marketing description',
|
||||
placeholder: 'Used to deliver personalised advertisements.',
|
||||
},
|
||||
{
|
||||
key: 'categoryPersonalisation',
|
||||
label: 'Personalisation category',
|
||||
placeholder: 'Personalisation',
|
||||
},
|
||||
{
|
||||
key: 'categoryPersonalisationDesc',
|
||||
label: 'Personalisation description',
|
||||
placeholder: 'Enable content personalisation.',
|
||||
},
|
||||
{
|
||||
key: 'cookieCount',
|
||||
label: 'Cookie count text',
|
||||
placeholder: '{{count}} cookies used on this site',
|
||||
},
|
||||
];
|
||||
|
||||
const COMMON_LOCALES = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'no', name: 'Norwegian' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'ro', name: 'Romanian' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'bg', name: 'Bulgarian' },
|
||||
{ code: 'hr', name: 'Croatian' },
|
||||
{ code: 'sk', name: 'Slovak' },
|
||||
{ code: 'sl', name: 'Slovenian' },
|
||||
{ code: 'el', name: 'Greek' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export default function SiteTranslationsTab({ siteId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedLocale, setSelectedLocale] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data: translations, isLoading } = useQuery({
|
||||
queryKey: ['sites', siteId, 'translations'],
|
||||
queryFn: () => listTranslations(siteId),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (locale: string) => deleteTranslation(siteId, locale),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
|
||||
setSelectedLocale(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const existing = translations ?? [];
|
||||
const selected = existing.find((t) => t.locale === selectedLocale);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Translations</h3>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
Manage banner text for different languages. English is the default fallback.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
Add language
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{existing.length === 0 ? (
|
||||
<EmptyState message="No translations yet. The banner will use English defaults." />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{existing.map((t) => (
|
||||
<button
|
||||
key={t.locale}
|
||||
onClick={() => setSelectedLocale(t.locale)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm font-medium transition ${
|
||||
selectedLocale === t.locale
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-border text-text-secondary hover:bg-mist'
|
||||
}`}
|
||||
>
|
||||
{localeName(t.locale)}
|
||||
<span className="ml-1.5 text-xs text-text-tertiary">{t.locale}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<TranslationEditor
|
||||
siteId={siteId}
|
||||
translation={selected}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete ${localeName(selected.locale)} translation?`)) {
|
||||
deleteMutation.mutate(selected.locale);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateTranslationModal
|
||||
open={showCreate}
|
||||
siteId={siteId}
|
||||
existingLocales={existing.map((t) => t.locale)}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(locale) => {
|
||||
setShowCreate(false);
|
||||
setSelectedLocale(locale);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Translation editor ──────────────────────────────────────────────── */
|
||||
|
||||
function TranslationEditor({
|
||||
siteId,
|
||||
translation,
|
||||
onDelete,
|
||||
}: {
|
||||
siteId: string;
|
||||
translation: Translation;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [strings, setStrings] = useState<Record<string, string>>(translation.strings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Reset state when switching locales
|
||||
const [currentLocale, setCurrentLocale] = useState(translation.locale);
|
||||
if (translation.locale !== currentLocale) {
|
||||
setStrings(translation.strings);
|
||||
setCurrentLocale(translation.locale);
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: { strings: Record<string, string> }) =>
|
||||
updateTranslation(siteId, translation.locale, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ strings });
|
||||
};
|
||||
|
||||
const filledCount = TRANSLATION_KEYS.filter((k) => strings[k.key]?.trim()).length;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
{localeName(translation.locale)}{' '}
|
||||
<span className="font-normal text-text-tertiary">({translation.locale})</span>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{filledCount}/{TRANSLATION_KEYS.length} strings translated. Empty strings fall back
|
||||
to English.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="text-xs text-status-error-fg hover:underline"
|
||||
>
|
||||
Delete language
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{TRANSLATION_KEYS.map(({ key, label, placeholder, multiline }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{label}
|
||||
<span className="ml-1 font-mono text-text-tertiary">{key}</span>
|
||||
</label>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
value={strings[key] ?? ''}
|
||||
onChange={(e) => setStrings({ ...strings, [key]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={strings[key] ?? ''}
|
||||
onChange={(e) => setStrings({ ...strings, [key]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save translation'}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-status-success-fg">Saved successfully</span>}
|
||||
{mutation.isError && (
|
||||
<span className="text-sm text-status-error-fg">Failed to save. Please try again.</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Create translation modal ────────────────────────────────────────── */
|
||||
|
||||
function CreateTranslationModal({
|
||||
open,
|
||||
siteId,
|
||||
existingLocales,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
siteId: string;
|
||||
existingLocales: string[];
|
||||
onClose: () => void;
|
||||
onCreated: (locale: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [locale, setLocale] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const availableLocales = COMMON_LOCALES.filter((l) => !existingLocales.includes(l.code));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createTranslation(siteId, { locale, strings: {} }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'translations'] });
|
||||
onCreated(locale);
|
||||
},
|
||||
onError: () => {
|
||||
setError('Failed to create translation. The locale may already exist.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!locale) return;
|
||||
setError('');
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Add language">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="error">{error}</Alert>
|
||||
)}
|
||||
<FormField label="Language" htmlFor="locale">
|
||||
<Select
|
||||
id="locale"
|
||||
required
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value)}
|
||||
>
|
||||
<option value="">Select a language...</option>
|
||||
{availableLocales.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name} ({l.code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || !locale}
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Add language'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function localeName(code: string): string {
|
||||
const match = COMMON_LOCALES.find((l) => l.code === code);
|
||||
return match?.name ?? code.toUpperCase();
|
||||
}
|
||||
35
apps/admin-ui/src/components/ui/alert.tsx
Normal file
35
apps/admin-ui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type HTMLAttributes, forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const alertVariants = cva("rounded-lg p-3 text-sm", {
|
||||
variants: {
|
||||
variant: {
|
||||
error: "bg-status-error-bg text-status-error-fg",
|
||||
success: "bg-status-success-bg text-status-success-fg",
|
||||
warning: "bg-status-warning-bg text-status-warning-fg",
|
||||
info: "bg-status-info-bg text-status-info-fg",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "error",
|
||||
},
|
||||
});
|
||||
|
||||
type AlertProps = HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof alertVariants>;
|
||||
|
||||
const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
export { Alert, alertVariants };
|
||||
export type { AlertProps };
|
||||
38
apps/admin-ui/src/components/ui/badge.tsx
Normal file
38
apps/admin-ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type HTMLAttributes, forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
success: "bg-status-success-bg text-status-success-fg",
|
||||
warning: "bg-status-warning-bg text-status-warning-fg",
|
||||
error: "bg-status-error-bg text-status-error-fg",
|
||||
info: "bg-status-info-bg text-status-info-fg",
|
||||
neutral: "bg-mist text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "neutral",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type BadgeProps = HTMLAttributes<HTMLSpanElement> &
|
||||
VariantProps<typeof badgeVariants>;
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(badgeVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export type { BadgeProps };
|
||||
49
apps/admin-ui/src/components/ui/button.tsx
Normal file
49
apps/admin-ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-copper underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
55
apps/admin-ui/src/components/ui/card.tsx
Normal file
55
apps/admin-ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type HTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border border-border bg-card shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
||||
22
apps/admin-ui/src/components/ui/empty-state.tsx
Normal file
22
apps/admin-ui/src/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface EmptyStateProps {
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function EmptyState({ message, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border-subtle p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmptyState };
|
||||
export type { EmptyStateProps };
|
||||
23
apps/admin-ui/src/components/ui/form-field.tsx
Normal file
23
apps/admin-ui/src/components/ui/form-field.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FormField({ label, htmlFor, error, children, className }: FormFieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className="text-sm font-medium text-foreground">{label}</label>
|
||||
{children}
|
||||
{error && <p className="text-sm text-status-error-fg">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormField };
|
||||
export type { FormFieldProps };
|
||||
19
apps/admin-ui/src/components/ui/input.tsx
Normal file
19
apps/admin-ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
20
apps/admin-ui/src/components/ui/loading-state.tsx
Normal file
20
apps/admin-ui/src/components/ui/loading-state.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function LoadingState({
|
||||
message = "Loading...",
|
||||
className,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoadingState };
|
||||
export type { LoadingStateProps };
|
||||
46
apps/admin-ui/src/components/ui/metric-card.tsx
Normal file
46
apps/admin-ui/src/components/ui/metric-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface MetricCardComparison {
|
||||
previous: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
comparison?: MetricCardComparison;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, comparison, className }: MetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card p-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{comparison && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<span
|
||||
className={
|
||||
comparison.direction === "up"
|
||||
? "text-status-success-fg"
|
||||
: "text-status-error-fg"
|
||||
}
|
||||
>
|
||||
{comparison.direction === "up" ? "\u2191" : "\u2193"}
|
||||
</span>{" "}
|
||||
vs {comparison.previous}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MetricCard };
|
||||
export type { MetricCardProps, MetricCardComparison };
|
||||
50
apps/admin-ui/src/components/ui/modal.tsx
Normal file
50
apps/admin-ui/src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Modal({ open, onClose, title, children, className }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-foreground/40"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Card */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"relative z-10 w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h2 className="font-heading text-lg font-semibold">{title}</h2>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Modal };
|
||||
export type { ModalProps };
|
||||
19
apps/admin-ui/src/components/ui/select.tsx
Normal file
19
apps/admin-ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type SelectHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
|
||||
export { Select };
|
||||
43
apps/admin-ui/src/components/ui/tab-group.tsx
Normal file
43
apps/admin-ui/src/components/ui/tab-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
interface TabOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TabGroupProps {
|
||||
options: TabOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function TabGroup({ options, value, onChange, className }: TabGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex rounded-md border border-border bg-mist p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded-sm px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
value === option.value
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TabGroup };
|
||||
export type { TabGroupProps, TabOption };
|
||||
19
apps/admin-ui/src/components/ui/textarea.tsx
Normal file
19
apps/admin-ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type TextareaHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Textarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
137
apps/admin-ui/src/extensions/registry.ts
Normal file
137
apps/admin-ui/src/extensions/registry.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* UI extension registry for the open-core architecture.
|
||||
*
|
||||
* Provides registration hooks that allow enterprise/commercial code to
|
||||
* inject additional tabs, pages, and navigation items into the admin UI
|
||||
* without the core needing any direct knowledge of the extensions.
|
||||
*
|
||||
* In community edition (CE) mode the registry is simply empty and the
|
||||
* UI renders only the built-in tabs/pages.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** A tab injected into the site-detail page. */
|
||||
export interface TabExtension {
|
||||
/** Unique identifier used as the tab key (e.g. ``"ee-analytics"``). */
|
||||
id: string;
|
||||
/** Human-readable label shown in the tab bar. */
|
||||
label: string;
|
||||
/**
|
||||
* React component rendered when the tab is active.
|
||||
*
|
||||
* Receives the same props that core tabs receive so extensions can
|
||||
* access the current site and config.
|
||||
*/
|
||||
component: ComponentType<SiteDetailTabProps>;
|
||||
/** Optional sort order — higher values appear further right. Core tabs use 0–100. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** Props forwarded to every site-detail tab (core and extension). */
|
||||
export interface SiteDetailTabProps {
|
||||
siteId: string;
|
||||
site: unknown;
|
||||
config: unknown;
|
||||
}
|
||||
|
||||
/** A page injected into the main router. */
|
||||
export interface PageExtension {
|
||||
/** Route path (e.g. ``"/ee/billing"``). */
|
||||
path: string;
|
||||
/** React component rendered at this route. */
|
||||
component: ComponentType;
|
||||
/** Whether the page requires authentication (default ``true``). */
|
||||
protected?: boolean;
|
||||
}
|
||||
|
||||
/** A navigation item injected into the top nav bar. */
|
||||
export interface NavExtension {
|
||||
/** Route path the link points to. */
|
||||
path: string;
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Optional sort order — higher values appear further right. Core items use 0–100. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const _tabs: TabExtension[] = [];
|
||||
const _pages: PageExtension[] = [];
|
||||
const _navItems: NavExtension[] = [];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Registration API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Register an additional tab on the site-detail page. */
|
||||
export function registerSiteDetailTab(tab: TabExtension): void {
|
||||
if (!_tabs.some((t) => t.id === tab.id)) {
|
||||
_tabs.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an additional page/route. */
|
||||
export function registerPage(page: PageExtension): void {
|
||||
if (!_pages.some((p) => p.path === page.path)) {
|
||||
_pages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an additional top-nav item. */
|
||||
export function registerNavItem(item: NavExtension): void {
|
||||
if (!_navItems.some((n) => n.path === item.path)) {
|
||||
_navItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Query API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Return all registered site-detail tabs, sorted by order. */
|
||||
export function getSiteDetailTabs(): readonly TabExtension[] {
|
||||
return [..._tabs].sort((a, b) => (a.order ?? 200) - (b.order ?? 200));
|
||||
}
|
||||
|
||||
/** Return all registered pages. */
|
||||
export function getPages(): readonly PageExtension[] {
|
||||
return [..._pages];
|
||||
}
|
||||
|
||||
/** Return all registered nav items, sorted by order. */
|
||||
export function getNavItems(): readonly NavExtension[] {
|
||||
return [..._navItems].sort((a, b) => (a.order ?? 200) - (b.order ?? 200));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Discovery */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Attempt to load enterprise UI extensions.
|
||||
*
|
||||
* In the OSS repo this is a no-op. In the cloud repo, the build
|
||||
* system replaces the virtual module ``virtual:ee-extensions`` with
|
||||
* the actual EE register module, enabling extension discovery.
|
||||
*/
|
||||
export async function discoverExtensions(): Promise<void> {
|
||||
try {
|
||||
// The virtual module is provided by the EE Vite plugin in the
|
||||
// cloud repo. In OSS builds the import fails and we fall through
|
||||
// to the catch block silently.
|
||||
const mod = await import('virtual:ee-extensions');
|
||||
if (mod) {
|
||||
console.info('[CMP] Enterprise UI extensions loaded');
|
||||
}
|
||||
} catch {
|
||||
// No EE extensions available — running in community edition mode.
|
||||
}
|
||||
}
|
||||
124
apps/admin-ui/src/index.css
Normal file
124
apps/admin-ui/src/index.css
Normal file
@@ -0,0 +1,124 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@fontsource/dm-sans/300.css";
|
||||
@import "@fontsource/dm-sans/400.css";
|
||||
@import "@fontsource/dm-sans/500.css";
|
||||
@import "@fontsource/sora/400.css";
|
||||
@import "@fontsource/sora/600.css";
|
||||
@import "@fontsource/sora/700.css";
|
||||
|
||||
/*
|
||||
ConsentOS palette (see assets/brand/README.md for the canonical
|
||||
reference). Hex values are encoded as-is via Tailwind v4's @theme
|
||||
directive — no oklch conversion needed since shadcn/ui consumes
|
||||
the CSS variables directly.
|
||||
*/
|
||||
:root {
|
||||
/* Surfaces & Backgrounds */
|
||||
--background: #FFFFFF; /* page background */
|
||||
--foreground: #0E1929; /* primary text — Ink */
|
||||
--card: #FFFFFF;
|
||||
--mist: #EEF3FF; /* Blue tint */
|
||||
--surface: #F5F8FC; /* Surface */
|
||||
|
||||
/* Accent colours */
|
||||
--primary: #1B3C7C; /* Navy — primary brand */
|
||||
--primary-foreground: #FFFFFF;
|
||||
--action: #2C6AE4; /* Blue — action / CTA */
|
||||
--accent-mid: #4D8AFF; /* Blue mid — accent / highlight */
|
||||
|
||||
/* Borders */
|
||||
--border: #DDE6F4;
|
||||
--border-subtle: #EEF3FF;
|
||||
--fog: #C4D5FA;
|
||||
--input: #DDE6F4;
|
||||
--ring: #2C6AE4;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-secondary: #5A6E96; /* Slate */
|
||||
--text-tertiary: #96AECE; /* Light slate */
|
||||
--muted-foreground: #5A6E96;
|
||||
|
||||
/* Status colours */
|
||||
--status-success-fg: #0DAA72; /* Consent green */
|
||||
--status-success-bg: #E6F8F1;
|
||||
--status-warning-fg: #B45309;
|
||||
--status-warning-bg: #FEF3C7;
|
||||
--status-error-fg: #B91C1C;
|
||||
--status-error-bg: #FEE2E2;
|
||||
--status-info-fg: #1B3C7C;
|
||||
--status-info-bg: #EEF3FF;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #EEF3FF; /* Blue tint */
|
||||
--secondary-foreground: #1B3C7C;
|
||||
|
||||
/* Accent (for hover states) */
|
||||
--accent: #EEF3FF;
|
||||
--accent-foreground: #1B3C7C;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Pencil design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-mist: var(--mist);
|
||||
--color-surface: var(--surface);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-action: var(--action);
|
||||
--color-accent-mid: var(--accent-mid);
|
||||
/* Backwards-compat alias: legacy "copper" token now maps to the
|
||||
ConsentOS action blue. New code should use ``--color-action``. */
|
||||
--color-copper: var(--action);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-fog: var(--fog);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-status-success-fg: var(--status-success-fg);
|
||||
--color-status-success-bg: var(--status-success-bg);
|
||||
--color-status-warning-fg: var(--status-warning-fg);
|
||||
--color-status-warning-bg: var(--status-warning-bg);
|
||||
--color-status-error-fg: var(--status-error-fg);
|
||||
--color-status-error-bg: var(--status-error-bg);
|
||||
--color-status-info-fg: var(--status-info-fg);
|
||||
--color-status-info-bg: var(--status-info-bg);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
/* Typography */
|
||||
--font-heading: "Sora", system-ui, sans-serif;
|
||||
--font-sans: "DM Sans", system-ui, sans-serif;
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased font-sans;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
6
apps/admin-ui/src/lib/utils.ts
Normal file
6
apps/admin-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
apps/admin-ui/src/main.tsx
Normal file
13
apps/admin-ui/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
628
apps/admin-ui/src/pages/ComplianceDashboardPage.tsx
Normal file
628
apps/admin-ui/src/pages/ComplianceDashboardPage.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { getComplianceScoreSummary, getComplianceScoreTrend } from '../api/compliance-scores';
|
||||
import { listSites } from '../api/sites';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
import { LoadingState } from '../components/ui/loading-state';
|
||||
import { Select } from '../components/ui/select';
|
||||
import { TabGroup } from '../components/ui/tab-group';
|
||||
import type {
|
||||
ComplianceScoreSummary,
|
||||
ComplianceScoreTrendPoint,
|
||||
ComplianceScoreTrendResponse,
|
||||
ComplianceStatus,
|
||||
Site,
|
||||
} from '../types/api';
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
type DateRange = '7d' | '30d' | '90d' | '12m';
|
||||
|
||||
const DATE_RANGE_OPTIONS: { value: DateRange; label: string; days: number }[] = [
|
||||
{ value: '7d', label: '7 days', days: 7 },
|
||||
{ value: '30d', label: '30 days', days: 30 },
|
||||
{ value: '90d', label: '90 days', days: 90 },
|
||||
{ value: '12m', label: '12 months', days: 365 },
|
||||
];
|
||||
|
||||
const FRAMEWORK_COLOURS: Record<string, string> = {
|
||||
gdpr: '#3b82f6',
|
||||
cnil: '#8b5cf6',
|
||||
ccpa: '#f59e0b',
|
||||
eprivacy: '#10b981',
|
||||
lgpd: '#ef4444',
|
||||
};
|
||||
|
||||
const FRAMEWORK_LABELS: Record<string, string> = {
|
||||
gdpr: 'GDPR',
|
||||
cnil: 'CNIL',
|
||||
ccpa: 'CCPA/CPRA',
|
||||
eprivacy: 'ePrivacy',
|
||||
lgpd: 'LGPD',
|
||||
};
|
||||
|
||||
type SeverityFilter = 'all' | 'critical' | 'warning' | 'info';
|
||||
|
||||
// ── Score change indicator ───────────────────────────────────────────
|
||||
|
||||
function ScoreChange({ current, previous }: { current: number; previous: number | null }) {
|
||||
if (previous === null) return <span className="text-xs text-text-tertiary">No prior data</span>;
|
||||
const diff = current - previous;
|
||||
if (diff === 0) return <span className="text-xs text-text-tertiary">No change</span>;
|
||||
const isPositive = diff > 0;
|
||||
return (
|
||||
<span className={`text-xs font-medium ${isPositive ? 'text-status-success-fg' : 'text-status-error-fg'}`}>
|
||||
{isPositive ? '+' : ''}{diff} vs yesterday
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview panel ───────────────────────────────────────────────────
|
||||
|
||||
function OverviewPanel({
|
||||
summary,
|
||||
trendData,
|
||||
}: {
|
||||
summary: ComplianceScoreSummary;
|
||||
trendData: ComplianceScoreTrendResponse | undefined;
|
||||
}) {
|
||||
// Calculate previous day scores for each framework from trend data
|
||||
const previousScores = useMemo(() => {
|
||||
if (!trendData?.data_points) return new Map<string, number>();
|
||||
const map = new Map<string, number>();
|
||||
const byFramework = new Map<string, ComplianceScoreTrendPoint[]>();
|
||||
for (const dp of trendData.data_points) {
|
||||
const list = byFramework.get(dp.framework) ?? [];
|
||||
list.push(dp);
|
||||
byFramework.set(dp.framework, list);
|
||||
}
|
||||
for (const [fw, points] of byFramework) {
|
||||
// Sort by date descending, take second entry as "previous"
|
||||
const sorted = [...points].sort(
|
||||
(a, b) => new Date(b.scanned_at).getTime() - new Date(a.scanned_at).getTime(),
|
||||
);
|
||||
if (sorted.length > 1) {
|
||||
map.set(fw, sorted[1].score);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [trendData]);
|
||||
|
||||
const scoreBadgeVariant = (score: number): 'success' | 'warning' | 'error' =>
|
||||
score > 90 ? 'success' : score >= 70 ? 'warning' : 'error';
|
||||
|
||||
const statusBadgeVariant = (status: ComplianceStatus): 'success' | 'warning' | 'error' => {
|
||||
const map: Record<ComplianceStatus, 'success' | 'warning' | 'error'> = {
|
||||
compliant: 'success',
|
||||
partial: 'warning',
|
||||
non_compliant: 'error',
|
||||
};
|
||||
return map[status];
|
||||
};
|
||||
|
||||
const statusLabels: Record<ComplianceStatus, string> = {
|
||||
compliant: 'Compliant',
|
||||
partial: 'Partial',
|
||||
non_compliant: 'Non-compliant',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Overall Compliance</h3>
|
||||
<Badge variant={scoreBadgeVariant(summary.overall_score)} className="text-lg font-bold px-3 py-1">
|
||||
{summary.overall_score}
|
||||
</Badge>
|
||||
</div>
|
||||
{summary.frameworks.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">No compliance scores recorded yet. Scores are computed daily.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{summary.frameworks.map((fw) => (
|
||||
<div key={fw.framework} className="rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{FRAMEWORK_LABELS[fw.framework] ?? fw.framework}
|
||||
</span>
|
||||
<Badge variant={scoreBadgeVariant(fw.score)} className="text-lg font-bold px-3 py-1">
|
||||
{fw.score}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<Badge variant={statusBadgeVariant(fw.status)}>
|
||||
{statusLabels[fw.status]}
|
||||
</Badge>
|
||||
<ScoreChange
|
||||
current={fw.score}
|
||||
previous={previousScores.get(fw.framework) ?? null}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-text-secondary">
|
||||
{fw.critical_count > 0 && (
|
||||
<span className="mr-2 text-status-error-fg">{fw.critical_count} critical</span>
|
||||
)}
|
||||
{fw.warning_count > 0 && (
|
||||
<span className="mr-2 text-status-warning-fg">{fw.warning_count} warning</span>
|
||||
)}
|
||||
{fw.info_count > 0 && (
|
||||
<span className="text-status-info-fg">{fw.info_count} info</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trend chart ──────────────────────────────────────────────────────
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string;
|
||||
[framework: string]: string | number;
|
||||
}
|
||||
|
||||
function TrendChart({
|
||||
trendData,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
}: {
|
||||
trendData: ComplianceScoreTrendResponse | undefined;
|
||||
dateRange: DateRange;
|
||||
onDateRangeChange: (range: DateRange) => void;
|
||||
}) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!trendData?.data_points || trendData.data_points.length === 0) return [];
|
||||
|
||||
// Group by date, with one key per framework
|
||||
const byDate = new Map<string, ChartDataPoint>();
|
||||
for (const dp of trendData.data_points) {
|
||||
const dateKey = new Date(dp.scanned_at).toISOString().split('T')[0];
|
||||
const existing = byDate.get(dateKey) ?? { date: dateKey };
|
||||
existing[dp.framework] = dp.score;
|
||||
byDate.set(dateKey, existing);
|
||||
}
|
||||
|
||||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [trendData]);
|
||||
|
||||
const frameworks = useMemo(() => {
|
||||
if (!trendData?.data_points) return [];
|
||||
return [...new Set(trendData.data_points.map((dp) => dp.framework))];
|
||||
}, [trendData]);
|
||||
|
||||
const tabOptions = DATE_RANGE_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }));
|
||||
|
||||
return (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Score Trends</h3>
|
||||
<TabGroup
|
||||
options={tabOptions}
|
||||
value={dateRange}
|
||||
onChange={(v) => onDateRangeChange(v as DateRange)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartData.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-text-secondary">
|
||||
No trend data available for this period.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v: string) => {
|
||||
const d = new Date(v);
|
||||
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
||||
}}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(String(v)).toLocaleDateString('en-GB')}
|
||||
formatter={(value: unknown, name: unknown) => [
|
||||
`${String(value)}/100`,
|
||||
FRAMEWORK_LABELS[String(name)] ?? String(name),
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) => FRAMEWORK_LABELS[value] ?? value}
|
||||
/>
|
||||
{frameworks.map((fw) => (
|
||||
<Line
|
||||
key={fw}
|
||||
type="monotone"
|
||||
dataKey={fw}
|
||||
stroke={FRAMEWORK_COLOURS[fw] ?? '#6b7280'}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issues table ─────────────────────────────────────────────────────
|
||||
|
||||
interface FlatIssue {
|
||||
framework: string;
|
||||
rule_id: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
scanned_at: string;
|
||||
}
|
||||
|
||||
function IssuesTable({ summary }: { summary: ComplianceScoreSummary }) {
|
||||
const [frameworkFilter, setFrameworkFilter] = useState<string>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
|
||||
const [sortField, setSortField] = useState<'framework' | 'severity' | 'scanned_at'>('severity');
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
// Flatten issues from all frameworks
|
||||
const allIssues = useMemo(() => {
|
||||
const issues: FlatIssue[] = [];
|
||||
for (const fw of summary.frameworks) {
|
||||
if (!fw.issues) continue;
|
||||
const issueList = Array.isArray(fw.issues) ? fw.issues : Object.values(fw.issues);
|
||||
for (const issue of issueList as Array<{
|
||||
rule_id?: string;
|
||||
severity?: string;
|
||||
message?: string;
|
||||
recommendation?: string;
|
||||
}>) {
|
||||
issues.push({
|
||||
framework: fw.framework,
|
||||
rule_id: issue.rule_id ?? 'unknown',
|
||||
severity: issue.severity ?? 'info',
|
||||
message: issue.message ?? '',
|
||||
recommendation: issue.recommendation ?? '',
|
||||
scanned_at: fw.scanned_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}, [summary]);
|
||||
|
||||
const filteredIssues = useMemo(() => {
|
||||
let result = allIssues;
|
||||
if (frameworkFilter !== 'all') {
|
||||
result = result.filter((i) => i.framework === frameworkFilter);
|
||||
}
|
||||
if (severityFilter !== 'all') {
|
||||
result = result.filter((i) => i.severity === severityFilter);
|
||||
}
|
||||
|
||||
const severityOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||
result.sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (sortField === 'severity') {
|
||||
cmp = (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3);
|
||||
} else if (sortField === 'framework') {
|
||||
cmp = a.framework.localeCompare(b.framework);
|
||||
} else {
|
||||
cmp = new Date(a.scanned_at).getTime() - new Date(b.scanned_at).getTime();
|
||||
}
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [allIssues, frameworkFilter, severityFilter, sortField, sortAsc]);
|
||||
|
||||
const handleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const frameworks = useMemo(
|
||||
() => [...new Set(allIssues.map((i) => i.framework))],
|
||||
[allIssues],
|
||||
);
|
||||
|
||||
const severityVariant: Record<string, 'error' | 'warning' | 'info' | 'neutral'> = {
|
||||
critical: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
};
|
||||
|
||||
if (allIssues.length === 0) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground mb-2">Issues</h3>
|
||||
<p className="text-sm text-text-secondary">No compliance issues detected. Well done!</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
Issues ({filteredIssues.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={frameworkFilter}
|
||||
onChange={(e) => setFrameworkFilter(e.target.value)}
|
||||
className="h-8 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="all">All frameworks</option>
|
||||
{frameworks.map((fw) => (
|
||||
<option key={fw} value={fw}>
|
||||
{FRAMEWORK_LABELS[fw] ?? fw}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value as SeverityFilter)}
|
||||
className="h-8 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="all">All severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-mist">
|
||||
<tr>
|
||||
<th
|
||||
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
|
||||
onClick={() => handleSort('framework')}
|
||||
>
|
||||
Framework {sortField === 'framework' ? (sortAsc ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
|
||||
onClick={() => handleSort('severity')}
|
||||
>
|
||||
Severity {sortField === 'severity' ? (sortAsc ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-text-secondary">Description</th>
|
||||
<th
|
||||
className="cursor-pointer px-3 py-2 text-left font-medium text-text-secondary hover:text-foreground"
|
||||
onClick={() => handleSort('scanned_at')}
|
||||
>
|
||||
Detected {sortField === 'scanned_at' ? (sortAsc ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredIssues.map((issue, idx) => {
|
||||
const rowKey = `${issue.framework}-${issue.rule_id}-${idx}`;
|
||||
const isExpanded = expandedRow === rowKey;
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className="cursor-pointer hover:bg-mist"
|
||||
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-foreground">
|
||||
{FRAMEWORK_LABELS[issue.framework] ?? issue.framework}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={severityVariant[issue.severity] ?? 'neutral'} className="text-xs font-semibold">
|
||||
{issue.severity}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-secondary">
|
||||
<div>{issue.message}</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded bg-mist p-2 text-xs text-text-secondary">
|
||||
<p className="font-medium text-foreground">Recommendation:</p>
|
||||
<p>{issue.recommendation}</p>
|
||||
<p className="mt-1 font-mono text-text-tertiary">{issue.rule_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-secondary">
|
||||
{new Date(issue.scanned_at).toLocaleDateString('en-GB')}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Export functions ──────────────────────────────────────────────────
|
||||
|
||||
function exportAsJson(summary: ComplianceScoreSummary): void {
|
||||
const report = {
|
||||
exported_at: new Date().toISOString(),
|
||||
site_id: summary.site_id,
|
||||
overall_score: summary.overall_score,
|
||||
frameworks: summary.frameworks.map((fw) => ({
|
||||
framework: fw.framework,
|
||||
score: fw.score,
|
||||
status: fw.status,
|
||||
critical_count: fw.critical_count,
|
||||
warning_count: fw.warning_count,
|
||||
info_count: fw.info_count,
|
||||
issues: fw.issues,
|
||||
scanned_at: fw.scanned_at,
|
||||
})),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `compliance-report-${summary.site_id}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportAsCsv(summary: ComplianceScoreSummary): void {
|
||||
const rows: string[] = [
|
||||
'Framework,Score,Status,Critical,Warning,Info,Scanned At',
|
||||
];
|
||||
|
||||
for (const fw of summary.frameworks) {
|
||||
rows.push(
|
||||
[
|
||||
FRAMEWORK_LABELS[fw.framework] ?? fw.framework,
|
||||
fw.score,
|
||||
fw.status,
|
||||
fw.critical_count,
|
||||
fw.warning_count,
|
||||
fw.info_count,
|
||||
fw.scanned_at,
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
|
||||
const blob = new Blob([rows.join('\n')], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `compliance-report-${summary.site_id}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Main page component ──────────────────────────────────────────────
|
||||
|
||||
export default function ComplianceDashboardPage() {
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<DateRange>('90d');
|
||||
|
||||
const days = DATE_RANGE_OPTIONS.find((opt) => opt.value === dateRange)?.days ?? 90;
|
||||
|
||||
// Fetch sites for the selector
|
||||
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
|
||||
queryKey: ['sites'],
|
||||
queryFn: listSites,
|
||||
});
|
||||
|
||||
// Auto-select the first site when sites load
|
||||
const effectiveSiteId = selectedSiteId ?? sites?.[0]?.id ?? null;
|
||||
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery<ComplianceScoreSummary>({
|
||||
queryKey: ['compliance-scores', effectiveSiteId],
|
||||
queryFn: () => getComplianceScoreSummary(effectiveSiteId!),
|
||||
enabled: !!effectiveSiteId,
|
||||
});
|
||||
|
||||
const { data: trendData } = useQuery<ComplianceScoreTrendResponse>({
|
||||
queryKey: ['compliance-scores', 'trend', effectiveSiteId, days],
|
||||
queryFn: () => getComplianceScoreTrend(effectiveSiteId!, { days }),
|
||||
enabled: !!effectiveSiteId,
|
||||
});
|
||||
|
||||
const handleSiteChange = useCallback((siteId: string) => {
|
||||
setSelectedSiteId(siteId);
|
||||
}, []);
|
||||
|
||||
if (sitesLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!sites || sites.length === 0) {
|
||||
return (
|
||||
<EmptyState message="No sites configured. Add a site first." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Compliance Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Continuous compliance monitoring with daily score tracking.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Site selector */}
|
||||
<Select
|
||||
value={effectiveSiteId ?? ''}
|
||||
onChange={(e) => handleSiteChange(e.target.value)}
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.display_name || site.domain}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Export buttons */}
|
||||
{summary && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportAsJson(summary)}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportAsCsv(summary)}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaryLoading ? (
|
||||
<LoadingState message="Loading compliance data..." />
|
||||
) : !summary ? (
|
||||
<EmptyState message="No compliance data available for this site. Scores are computed daily at 04:00 UTC." />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Overview panel */}
|
||||
<OverviewPanel summary={summary} trendData={trendData} />
|
||||
|
||||
{/* Score trend chart */}
|
||||
<TrendChart
|
||||
trendData={trendData}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
|
||||
{/* Issues table */}
|
||||
<IssuesTable summary={summary} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/admin-ui/src/pages/LoginPage.tsx
Normal file
84
apps/admin-ui/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { Button } from '../components/ui/button.tsx';
|
||||
import { Input } from '../components/ui/input.tsx';
|
||||
import { FormField } from '../components/ui/form-field.tsx';
|
||||
import { Alert } from '../components/ui/alert.tsx';
|
||||
import { Card, CardContent } from '../components/ui/card.tsx';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { isAuthenticated, isLoading, login } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/sites" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/sites');
|
||||
} catch {
|
||||
setError('Invalid email or password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
<Card>
|
||||
<CardContent className="px-8 py-10">
|
||||
<div className="mb-2 flex items-center justify-center gap-3">
|
||||
<img src="/logo-mark.svg" alt="" width="32" height="32" aria-hidden="true" />
|
||||
<h1 className="font-heading text-2xl font-semibold text-foreground">
|
||||
<span className="text-primary">Consent</span>
|
||||
<span className="text-action">OS</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mb-8 text-center text-sm text-text-secondary">
|
||||
Sign in to manage your consent platform
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
apps/admin-ui/src/pages/SettingsPage.tsx
Normal file
386
apps/admin-ui/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { getOrgConfig, updateOrgConfig } from '../api/org-config';
|
||||
import { trackConfigChange } from '../services/analytics';
|
||||
import BannerBuilderTab from '../components/BannerBuilderTab';
|
||||
import { Alert } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { FormField } from '../components/ui/form-field';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { LoadingState } from '../components/ui/loading-state';
|
||||
import { Select } from '../components/ui/select';
|
||||
|
||||
const GPP_SECTIONS = [
|
||||
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
|
||||
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
|
||||
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
|
||||
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
|
||||
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
|
||||
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
|
||||
];
|
||||
|
||||
const GPC_JURISDICTIONS = [
|
||||
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
|
||||
{ value: 'US-CO', label: 'Colorado (CPA)' },
|
||||
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
|
||||
{ value: 'US-TX', label: 'Texas (TDPSA)' },
|
||||
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
|
||||
];
|
||||
|
||||
type Tab = 'configuration' | 'banner';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('configuration');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ['org-config'],
|
||||
queryFn: getOrgConfig,
|
||||
});
|
||||
|
||||
// ── Form state (all nullable for org-level tri-state) ──────────────
|
||||
const [blockingMode, setBlockingMode] = useState<string>('');
|
||||
const [tcfEnabled, setTcfEnabled] = useState<string>('');
|
||||
const [gcmEnabled, setGcmEnabled] = useState<string>('');
|
||||
const [shopifyEnabled, setShopifyEnabled] = useState<string>('');
|
||||
const [consentExpiry, setConsentExpiry] = useState<string>('');
|
||||
const [privacyUrl, setPrivacyUrl] = useState<string>('');
|
||||
const [termsUrl, setTermsUrl] = useState<string>('');
|
||||
|
||||
// GPP state
|
||||
const [gppEnabled, setGppEnabled] = useState<string>('');
|
||||
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>([]);
|
||||
|
||||
// GPC state
|
||||
const [gpcEnabled, setGpcEnabled] = useState<string>('');
|
||||
const [gpcGlobalHonour, setGpcGlobalHonour] = useState<string>('');
|
||||
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>([]);
|
||||
|
||||
// Sync local state when config loads
|
||||
const [initialised, setInitialised] = useState(false);
|
||||
if (config && !initialised) {
|
||||
setBlockingMode(config.blocking_mode ?? '');
|
||||
setTcfEnabled(config.tcf_enabled === null ? '' : config.tcf_enabled ? 'true' : 'false');
|
||||
setGcmEnabled(config.gcm_enabled === null ? '' : config.gcm_enabled ? 'true' : 'false');
|
||||
setShopifyEnabled(config.shopify_privacy_enabled === null ? '' : config.shopify_privacy_enabled ? 'true' : 'false');
|
||||
setConsentExpiry(config.consent_expiry_days?.toString() ?? '');
|
||||
setPrivacyUrl(config.privacy_policy_url ?? '');
|
||||
setTermsUrl(config.terms_url ?? '');
|
||||
setGppEnabled(config.gpp_enabled === null ? '' : config.gpp_enabled ? 'true' : 'false');
|
||||
setGppSupportedApis(config.gpp_supported_apis ?? []);
|
||||
setGpcEnabled(config.gpc_enabled === null ? '' : config.gpc_enabled ? 'true' : 'false');
|
||||
setGpcGlobalHonour(config.gpc_global_honour === null ? '' : config.gpc_global_honour ? 'true' : 'false');
|
||||
setGpcJurisdictions(config.gpc_jurisdictions ?? []);
|
||||
setInitialised(true);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateOrgConfig,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['org-config'] });
|
||||
trackConfigChange('org_config');
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({
|
||||
blocking_mode: (blockingMode || null) as 'opt_in' | 'opt_out' | 'informational' | null,
|
||||
tcf_enabled: tcfEnabled === '' ? null : tcfEnabled === 'true',
|
||||
gcm_enabled: gcmEnabled === '' ? null : gcmEnabled === 'true',
|
||||
shopify_privacy_enabled: shopifyEnabled === '' ? null : shopifyEnabled === 'true',
|
||||
consent_expiry_days: consentExpiry === '' ? null : Number(consentExpiry),
|
||||
privacy_policy_url: privacyUrl || null,
|
||||
terms_url: termsUrl || null,
|
||||
gpp_enabled: gppEnabled === '' ? null : gppEnabled === 'true',
|
||||
gpp_supported_apis: gppEnabled === 'true' && gppSupportedApis.length > 0 ? gppSupportedApis : null,
|
||||
gpc_enabled: gpcEnabled === '' ? null : gpcEnabled === 'true',
|
||||
gpc_global_honour: gpcGlobalHonour === '' ? null : gpcGlobalHonour === 'true',
|
||||
gpc_jurisdictions: gpcEnabled === 'true' && gpcJurisdictions.length > 0 ? gpcJurisdictions : null,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGppSection = (api: string) => {
|
||||
setGppSupportedApis((prev) =>
|
||||
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleGpcJurisdiction = (code: string) => {
|
||||
setGpcJurisdictions((prev) =>
|
||||
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'configuration', label: 'Configuration' },
|
||||
{ key: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Organisation settings</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Set default configuration for all sites in your organisation. Individual sites can
|
||||
override these values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-copper font-medium text-foreground'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{activeTab === 'configuration' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Cascade explanation banner */}
|
||||
<div className="rounded-lg border border-dashed border-border bg-background p-4">
|
||||
<p className="text-xs text-text-secondary">
|
||||
<strong>Configuration cascade:</strong> System defaults → Organisation defaults (this
|
||||
page) → Site group defaults → Site-level config → Regional overrides. Each level
|
||||
only overrides fields that are explicitly set. Leave a field empty to inherit the
|
||||
system default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Consent settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">Default consent settings</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
These defaults apply to all sites unless overridden at site or group level.
|
||||
Leave a field empty to use the system default.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<FormField label="Default blocking mode">
|
||||
<Select
|
||||
value={blockingMode}
|
||||
onChange={(e) => setBlockingMode(e.target.value)}
|
||||
>
|
||||
<option value="">System default (opt-in)</option>
|
||||
<option value="opt_in">Opt-in (GDPR)</option>
|
||||
<option value="opt_out">Opt-out (CCPA)</option>
|
||||
<option value="informational">Informational only</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default consent expiry (days)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={730}
|
||||
value={consentExpiry}
|
||||
onChange={(e) => setConsentExpiry(e.target.value)}
|
||||
placeholder="System default (365)"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default privacy policy URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={privacyUrl}
|
||||
onChange={(e) => setPrivacyUrl(e.target.value)}
|
||||
placeholder="https://example.com/privacy"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default terms & conditions URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={termsUrl}
|
||||
onChange={(e) => setTermsUrl(e.target.value)}
|
||||
placeholder="https://example.com/terms"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Standards & integrations */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">Default standards & integrations</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
Control whether standards and integrations are enabled by default across all sites.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<FormField label="IAB TCF v2.2">
|
||||
<Select
|
||||
value={tcfEnabled}
|
||||
onChange={(e) => setTcfEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">System default (disabled)</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Google Consent Mode v2">
|
||||
<Select
|
||||
value={gcmEnabled}
|
||||
onChange={(e) => setGcmEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">System default (enabled)</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Shopify Customer Privacy API">
|
||||
<Select
|
||||
value={shopifyEnabled}
|
||||
onChange={(e) => setShopifyEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">System default (disabled)</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* IAB Global Privacy Platform (GPP) */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">IAB Global Privacy Platform (GPP)</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPP provides a standardised consent string format for US state privacy laws.
|
||||
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP strings
|
||||
for the selected sections.
|
||||
</p>
|
||||
|
||||
<FormField label="Enable GPP">
|
||||
<Select
|
||||
value={gppEnabled}
|
||||
onChange={(e) => setGppEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">System default</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gppEnabled === 'true' && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
|
||||
{GPP_SECTIONS.map((section) => (
|
||||
<label key={section.value} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gppSupportedApis.includes(section.value)}
|
||||
onChange={() => toggleGppSection(section.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{section.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Global Privacy Control (GPC) */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Global Privacy Control (GPC)</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPC is a browser signal indicating a user's intent to opt out of the sale or
|
||||
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
|
||||
legally require businesses to honour this signal.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField label="Detect GPC signal">
|
||||
<Select
|
||||
value={gpcEnabled}
|
||||
onChange={(e) => setGpcEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">System default</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gpcEnabled === 'true' && (
|
||||
<div className="space-y-4">
|
||||
<FormField label="Honour globally">
|
||||
<Select
|
||||
value={gpcGlobalHonour}
|
||||
onChange={(e) => setGpcGlobalHonour(e.target.value)}
|
||||
>
|
||||
<option value="">System default</option>
|
||||
<option value="true">Enabled — apply GPC opt-out for all visitors regardless of jurisdiction</option>
|
||||
<option value="false">Disabled — only honour in selected jurisdictions</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gpcGlobalHonour !== 'true' && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">
|
||||
Jurisdictions where GPC is legally required
|
||||
</p>
|
||||
{GPC_JURISDICTIONS.map((j) => (
|
||||
<label key={j.value} className="flex items-center gap-2 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpcJurisdictions.includes(j.value)}
|
||||
onChange={() => toggleGpcJurisdiction(j.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{j.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save defaults'}
|
||||
</Button>
|
||||
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
|
||||
{mutation.isError && (
|
||||
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'banner' && config && (
|
||||
<BannerBuilderTab
|
||||
configQueryKey={['org-config']}
|
||||
config={config}
|
||||
onSave={(body) => updateOrgConfig(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/admin-ui/src/pages/SiteDetailPage.tsx
Normal file
119
apps/admin-ui/src/pages/SiteDetailPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
||||
import SiteComplianceTab from '../components/SiteComplianceTab';
|
||||
import SiteConfigTab from '../components/SiteConfigTab';
|
||||
import SiteCookiesTab from '../components/SiteCookiesTab';
|
||||
import SiteOverviewTab from '../components/SiteOverviewTab';
|
||||
import BannerBuilderTab from '../components/BannerBuilderTab';
|
||||
import SiteScannerTab from '../components/SiteScannerTab';
|
||||
import SiteTranslationsTab from '../components/SiteTranslationsTab';
|
||||
import { LoadingState } from '../components/ui/loading-state.tsx';
|
||||
import { getSiteDetailTabs } from '../extensions/registry';
|
||||
|
||||
const CORE_TABS: { id: string; label: string; order: number }[] = [
|
||||
{ id: 'overview', label: 'Overview', order: 10 },
|
||||
{ id: 'config', label: 'Configuration', order: 20 },
|
||||
{ id: 'cookies', label: 'Cookies', order: 30 },
|
||||
{ id: 'banner', label: 'Banner', order: 40 },
|
||||
{ id: 'translations', label: 'Translations', order: 50 },
|
||||
{ id: 'scanner', label: 'Scans', order: 60 },
|
||||
{ id: 'compliance', label: 'Compliance', order: 70 },
|
||||
];
|
||||
|
||||
export default function SiteDetailPage() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
||||
|
||||
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
|
||||
const allTabs = useMemo(() => {
|
||||
const ext = extensionTabs.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
order: t.order ?? 200,
|
||||
}));
|
||||
return [...CORE_TABS, ...ext].sort((a, b) => a.order - b.order);
|
||||
}, [extensionTabs]);
|
||||
|
||||
const { data: site, isLoading: siteLoading } = useQuery({
|
||||
queryKey: ['sites', siteId],
|
||||
queryFn: () => getSite(siteId!),
|
||||
enabled: !!siteId,
|
||||
});
|
||||
|
||||
const { data: config, isLoading: configLoading } = useQuery({
|
||||
queryKey: ['sites', siteId, 'config'],
|
||||
queryFn: () => getSiteConfig(siteId!),
|
||||
enabled: !!siteId,
|
||||
});
|
||||
|
||||
if (siteLoading || configLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
return <div className="py-12 text-center text-sm text-status-error-fg">Site not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
|
||||
{site.display_name ?? site.name ?? site.domain}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">{site.domain}</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs — horizontally scrollable on mobile, copper underline */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
|
||||
{allTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-copper font-medium text-foreground'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content — core tabs */}
|
||||
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
|
||||
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
|
||||
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
|
||||
{activeTab === 'banner' && siteId && (
|
||||
<BannerBuilderTab
|
||||
configQueryKey={['sites', siteId, 'config']}
|
||||
config={config ?? null}
|
||||
onSave={(body) => updateSiteConfig(siteId, body)}
|
||||
siteDomain={site.domain}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'translations' && siteId && <SiteTranslationsTab siteId={siteId} />}
|
||||
{activeTab === 'scanner' && siteId && <SiteScannerTab siteId={siteId} />}
|
||||
{activeTab === 'compliance' && siteId && <SiteComplianceTab siteId={siteId} config={config ?? null} />}
|
||||
{/* Extension tabs */}
|
||||
{extensionTabs.map(
|
||||
(ext) =>
|
||||
activeTab === ext.id &&
|
||||
siteId && (
|
||||
<ext.component
|
||||
key={ext.id}
|
||||
siteId={siteId}
|
||||
site={site}
|
||||
config={config ?? null}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
420
apps/admin-ui/src/pages/SiteGroupDetailPage.tsx
Normal file
420
apps/admin-ui/src/pages/SiteGroupDetailPage.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getSiteGroup } from '../api/site-groups';
|
||||
import { getSiteGroupConfig, updateSiteGroupConfig } from '../api/site-group-config';
|
||||
import BannerBuilderTab from '../components/BannerBuilderTab';
|
||||
import { Alert } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { FormField } from '../components/ui/form-field';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { LoadingState } from '../components/ui/loading-state';
|
||||
import { Select } from '../components/ui/select';
|
||||
|
||||
const GPP_SECTIONS = [
|
||||
{ value: 'usnat', label: 'US National Privacy (Section 7)' },
|
||||
{ value: 'usca', label: 'US California — CCPA/CPRA (Section 8)' },
|
||||
{ value: 'usva', label: 'US Virginia — VCDPA (Section 9)' },
|
||||
{ value: 'usco', label: 'US Colorado — CPA (Section 10)' },
|
||||
{ value: 'usct', label: 'US Connecticut — CTDPA (Section 11)' },
|
||||
{ value: 'usfl', label: 'US Florida — FDBR (Section 14)' },
|
||||
];
|
||||
|
||||
const GPC_JURISDICTIONS = [
|
||||
{ value: 'US-CA', label: 'California (CCPA/CPRA)' },
|
||||
{ value: 'US-CO', label: 'Colorado (CPA)' },
|
||||
{ value: 'US-CT', label: 'Connecticut (CTDPA)' },
|
||||
{ value: 'US-TX', label: 'Texas (TDPSA)' },
|
||||
{ value: 'US-MT', label: 'Montana (MTCDPA)' },
|
||||
];
|
||||
|
||||
type Tab = 'configuration' | 'banner';
|
||||
|
||||
export default function SiteGroupDetailPage() {
|
||||
const { groupId } = useParams<{ groupId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('configuration');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const { data: group, isLoading: groupLoading } = useQuery({
|
||||
queryKey: ['site-groups', groupId],
|
||||
queryFn: () => getSiteGroup(groupId!),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
|
||||
const { data: config, isLoading: configLoading } = useQuery({
|
||||
queryKey: ['site-group-config', groupId],
|
||||
queryFn: () => getSiteGroupConfig(groupId!),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
|
||||
// ── Form state (all nullable — empty = inherit from org/system) ────
|
||||
const [blockingMode, setBlockingMode] = useState<string>('');
|
||||
const [tcfEnabled, setTcfEnabled] = useState<string>('');
|
||||
const [gcmEnabled, setGcmEnabled] = useState<string>('');
|
||||
const [shopifyEnabled, setShopifyEnabled] = useState<string>('');
|
||||
const [consentExpiry, setConsentExpiry] = useState<string>('');
|
||||
const [privacyUrl, setPrivacyUrl] = useState<string>('');
|
||||
const [termsUrl, setTermsUrl] = useState<string>('');
|
||||
|
||||
// GPP state
|
||||
const [gppEnabled, setGppEnabled] = useState<string>('');
|
||||
const [gppSupportedApis, setGppSupportedApis] = useState<string[]>([]);
|
||||
|
||||
// GPC state
|
||||
const [gpcEnabled, setGpcEnabled] = useState<string>('');
|
||||
const [gpcGlobalHonour, setGpcGlobalHonour] = useState<string>('');
|
||||
const [gpcJurisdictions, setGpcJurisdictions] = useState<string[]>([]);
|
||||
|
||||
// Sync local state when config loads
|
||||
const [initialised, setInitialised] = useState(false);
|
||||
if (config && !initialised) {
|
||||
setBlockingMode(config.blocking_mode ?? '');
|
||||
setTcfEnabled(config.tcf_enabled === null ? '' : config.tcf_enabled ? 'true' : 'false');
|
||||
setGcmEnabled(config.gcm_enabled === null ? '' : config.gcm_enabled ? 'true' : 'false');
|
||||
setShopifyEnabled(
|
||||
config.shopify_privacy_enabled === null ? '' : config.shopify_privacy_enabled ? 'true' : 'false',
|
||||
);
|
||||
setConsentExpiry(config.consent_expiry_days?.toString() ?? '');
|
||||
setPrivacyUrl(config.privacy_policy_url ?? '');
|
||||
setTermsUrl(config.terms_url ?? '');
|
||||
setGppEnabled(config.gpp_enabled === null || config.gpp_enabled === undefined ? '' : config.gpp_enabled ? 'true' : 'false');
|
||||
setGppSupportedApis(config.gpp_supported_apis ?? []);
|
||||
setGpcEnabled(config.gpc_enabled === null || config.gpc_enabled === undefined ? '' : config.gpc_enabled ? 'true' : 'false');
|
||||
setGpcGlobalHonour(config.gpc_global_honour === null || config.gpc_global_honour === undefined ? '' : config.gpc_global_honour ? 'true' : 'false');
|
||||
setGpcJurisdictions(config.gpc_jurisdictions ?? []);
|
||||
setInitialised(true);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => updateSiteGroupConfig(groupId!, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['site-group-config', groupId] });
|
||||
// Invalidate inheritance for all sites in this group
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({
|
||||
blocking_mode: blockingMode || null,
|
||||
tcf_enabled: tcfEnabled === '' ? null : tcfEnabled === 'true',
|
||||
gcm_enabled: gcmEnabled === '' ? null : gcmEnabled === 'true',
|
||||
shopify_privacy_enabled: shopifyEnabled === '' ? null : shopifyEnabled === 'true',
|
||||
consent_expiry_days: consentExpiry === '' ? null : Number(consentExpiry),
|
||||
privacy_policy_url: privacyUrl || null,
|
||||
terms_url: termsUrl || null,
|
||||
gpp_enabled: gppEnabled === '' ? null : gppEnabled === 'true',
|
||||
gpp_supported_apis: gppEnabled === 'true' && gppSupportedApis.length > 0 ? gppSupportedApis : null,
|
||||
gpc_enabled: gpcEnabled === '' ? null : gpcEnabled === 'true',
|
||||
gpc_global_honour: gpcGlobalHonour === '' ? null : gpcGlobalHonour === 'true',
|
||||
gpc_jurisdictions: gpcEnabled === 'true' && gpcJurisdictions.length > 0 ? gpcJurisdictions : null,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGppSection = (api: string) => {
|
||||
setGppSupportedApis((prev) =>
|
||||
prev.includes(api) ? prev.filter((a) => a !== api) : [...prev, api],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleGpcJurisdiction = (code: string) => {
|
||||
setGpcJurisdictions((prev) =>
|
||||
prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code],
|
||||
);
|
||||
};
|
||||
|
||||
if (groupLoading || configLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return <div className="py-12 text-center text-sm text-status-error-fg">Group not found</div>;
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'configuration', label: 'Configuration' },
|
||||
{ key: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
|
||||
{group.name}
|
||||
</h1>
|
||||
{group.description && (
|
||||
<p className="mt-1 text-sm text-text-secondary">{group.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Site group · {group.site_count} {group.site_count === 1 ? 'site' : 'sites'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex gap-8 overflow-x-auto border-b border-border-subtle">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`shrink-0 whitespace-nowrap border-b-2 pb-3 font-heading text-sm transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-copper font-medium text-foreground'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'configuration' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Cascade explanation banner */}
|
||||
<div className="rounded-lg border border-dashed border-border bg-background p-4">
|
||||
<p className="text-xs text-text-secondary">
|
||||
<strong>Configuration cascade:</strong> System defaults → Organisation defaults
|
||||
→ <span className="font-semibold">Group defaults (this page)</span> →
|
||||
Site-level config → Regional overrides. Each level only overrides fields that
|
||||
are explicitly set. Leave a field empty to inherit from the organisation or system
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Consent settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">
|
||||
Default consent settings
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
These defaults apply to all sites in this group unless overridden at site level.
|
||||
Leave a field empty to inherit from the organisation or system default.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<FormField label="Default blocking mode">
|
||||
<Select
|
||||
value={blockingMode}
|
||||
onChange={(e) => setBlockingMode(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="opt_in">Opt-in (GDPR)</option>
|
||||
<option value="opt_out">Opt-out (CCPA)</option>
|
||||
<option value="informational">Informational only</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default consent expiry (days)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={730}
|
||||
value={consentExpiry}
|
||||
onChange={(e) => setConsentExpiry(e.target.value)}
|
||||
placeholder="Inherit from org/system"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default privacy policy URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={privacyUrl}
|
||||
onChange={(e) => setPrivacyUrl(e.target.value)}
|
||||
placeholder="https://example.com/privacy"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Default terms & conditions URL">
|
||||
<Input
|
||||
type="url"
|
||||
value={termsUrl}
|
||||
onChange={(e) => setTermsUrl(e.target.value)}
|
||||
placeholder="https://example.com/terms"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Standards & integrations */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-1 text-sm font-semibold text-foreground">
|
||||
Default standards & integrations
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
Control whether standards and integrations are enabled by default for sites in this
|
||||
group.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<FormField label="IAB TCF v2.2">
|
||||
<Select
|
||||
value={tcfEnabled}
|
||||
onChange={(e) => setTcfEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Google Consent Mode v2">
|
||||
<Select
|
||||
value={gcmEnabled}
|
||||
onChange={(e) => setGcmEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Shopify Customer Privacy API">
|
||||
<Select
|
||||
value={shopifyEnabled}
|
||||
onChange={(e) => setShopifyEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* IAB Global Privacy Platform (GPP) */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
|
||||
IAB Global Privacy Platform (GPP)
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPP provides a standardised consent string format for US state privacy laws.
|
||||
When enabled, the banner exposes the <code>__gpp()</code> API and generates GPP
|
||||
strings for the selected sections.
|
||||
</p>
|
||||
|
||||
<FormField label="Enable GPP">
|
||||
<Select
|
||||
value={gppEnabled}
|
||||
onChange={(e) => setGppEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gppEnabled === 'true' && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">Supported sections</p>
|
||||
{GPP_SECTIONS.map((section) => (
|
||||
<label key={section.value} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gppSupportedApis.includes(section.value)}
|
||||
onChange={() => toggleGppSection(section.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{section.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Global Privacy Control (GPC) */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
|
||||
Global Privacy Control (GPC)
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-text-secondary">
|
||||
GPC is a browser signal indicating a user's intent to opt out of the sale or
|
||||
sharing of their personal data. Several US state laws (CCPA, CPA, CTDPA, TDPSA, MTCDPA)
|
||||
legally require businesses to honour this signal.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField label="Detect GPC signal">
|
||||
<Select
|
||||
value={gpcEnabled}
|
||||
onChange={(e) => setGpcEnabled(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gpcEnabled === 'true' && (
|
||||
<div className="space-y-4">
|
||||
<FormField label="Honour globally">
|
||||
<Select
|
||||
value={gpcGlobalHonour}
|
||||
onChange={(e) => setGpcGlobalHonour(e.target.value)}
|
||||
>
|
||||
<option value="">Inherit from org/system</option>
|
||||
<option value="true">Enabled — apply GPC opt-out for all visitors regardless of jurisdiction</option>
|
||||
<option value="false">Disabled — only honour in selected jurisdictions</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{gpcGlobalHonour !== 'true' && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-text-secondary">
|
||||
Jurisdictions where GPC is legally required
|
||||
</p>
|
||||
{GPC_JURISDICTIONS.map((j) => (
|
||||
<label key={j.value} className="flex items-center gap-2 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpcJurisdictions.includes(j.value)}
|
||||
onChange={() => toggleGpcJurisdiction(j.value)}
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{j.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save defaults'}
|
||||
</Button>
|
||||
{saved && <Alert variant="success" className="inline-flex w-auto p-2">Saved successfully</Alert>}
|
||||
{mutation.isError && (
|
||||
<Alert variant="error" className="inline-flex w-auto p-2">Failed to save. Please try again.</Alert>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'banner' && config && groupId && (
|
||||
<BannerBuilderTab
|
||||
configQueryKey={['site-group-config', groupId]}
|
||||
config={config}
|
||||
onSave={(body) => updateSiteGroupConfig(groupId, body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
apps/admin-ui/src/pages/SitesPage.tsx
Normal file
447
apps/admin-ui/src/pages/SitesPage.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { createSiteGroup, deleteSiteGroup, listSiteGroups } from '../api/site-groups';
|
||||
import { listSites, updateSite } from '../api/sites';
|
||||
import CreateSiteModal from '../components/CreateSiteModal';
|
||||
import { Button } from '../components/ui/button.tsx';
|
||||
import { Badge } from '../components/ui/badge.tsx';
|
||||
import { Modal } from '../components/ui/modal.tsx';
|
||||
import { FormField } from '../components/ui/form-field.tsx';
|
||||
import { Input } from '../components/ui/input.tsx';
|
||||
import { Alert } from '../components/ui/alert.tsx';
|
||||
import { EmptyState } from '../components/ui/empty-state.tsx';
|
||||
import { LoadingState } from '../components/ui/loading-state.tsx';
|
||||
import type { Site, SiteGroup } from '../types/api';
|
||||
|
||||
export default function SitesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [createGroupError, setCreateGroupError] = useState('');
|
||||
const { data: sites, isLoading: sitesLoading, error: sitesError } = useQuery({
|
||||
queryKey: ['sites'],
|
||||
queryFn: listSites,
|
||||
});
|
||||
|
||||
const { data: groups, isLoading: groupsLoading } = useQuery({
|
||||
queryKey: ['site-groups'],
|
||||
queryFn: listSiteGroups,
|
||||
});
|
||||
|
||||
const createGroupMutation = useMutation({
|
||||
mutationFn: createSiteGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
|
||||
setShowCreateGroup(false);
|
||||
setNewGroupName('');
|
||||
setCreateGroupError('');
|
||||
},
|
||||
onError: () => {
|
||||
setCreateGroupError('Failed to create group. Name may already exist.');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteGroupMutation = useMutation({
|
||||
mutationFn: deleteSiteGroup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
},
|
||||
});
|
||||
|
||||
const assignGroupMutation = useMutation({
|
||||
mutationFn: ({ siteId, groupId }: { siteId: string; groupId: string | null }) =>
|
||||
updateSite(siteId, { site_group_id: groupId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['site-groups'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Group sites by site_group_id
|
||||
const { groupedSites, ungroupedSites } = useMemo(() => {
|
||||
if (!sites) return { groupedSites: new Map<string, Site[]>(), ungroupedSites: [] };
|
||||
const grouped = new Map<string, Site[]>();
|
||||
const ungrouped: Site[] = [];
|
||||
for (const site of sites) {
|
||||
if (site.site_group_id) {
|
||||
const list = grouped.get(site.site_group_id) ?? [];
|
||||
list.push(site);
|
||||
grouped.set(site.site_group_id, list);
|
||||
} else {
|
||||
ungrouped.push(site);
|
||||
}
|
||||
}
|
||||
return { groupedSites: grouped, ungroupedSites: ungrouped };
|
||||
}, [sites]);
|
||||
|
||||
const isLoading = sitesLoading || groupsLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Sites</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Manage your consent-enabled websites
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowCreateGroup(true)}>
|
||||
New group
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
Add site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingState />}
|
||||
|
||||
{sitesError && (
|
||||
<Alert variant="error">
|
||||
Failed to load sites. Please try again.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && sites && sites.length === 0 && (!groups || groups.length === 0) && (
|
||||
<EmptyState message="No sites yet. Add your first site to get started." />
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="space-y-6">
|
||||
{/* Render each group */}
|
||||
{groups?.map((group) => (
|
||||
<GroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
sites={groupedSites.get(group.id) ?? []}
|
||||
allGroups={groups}
|
||||
groupId={group.id}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete group "${group.name}"? Sites will become ungrouped.`)) {
|
||||
deleteGroupMutation.mutate(group.id);
|
||||
}
|
||||
}}
|
||||
onRemoveSite={(siteId) =>
|
||||
assignGroupMutation.mutate({ siteId, groupId: null })
|
||||
}
|
||||
onMoveSite={(siteId, groupId) =>
|
||||
assignGroupMutation.mutate({ siteId, groupId })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped sites */}
|
||||
{ungroupedSites.length > 0 && (
|
||||
<div>
|
||||
{groups && groups.length > 0 && (
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
Ungrouped sites
|
||||
</h2>
|
||||
)}
|
||||
<SiteTable
|
||||
sites={ungroupedSites}
|
||||
groups={groups ?? []}
|
||||
onAssignGroup={(siteId, groupId) =>
|
||||
assignGroupMutation.mutate({ siteId, groupId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && <CreateSiteModal onClose={() => setShowCreate(false)} />}
|
||||
|
||||
<Modal
|
||||
open={showCreateGroup}
|
||||
onClose={() => {
|
||||
setShowCreateGroup(false);
|
||||
setNewGroupName('');
|
||||
setCreateGroupError('');
|
||||
}}
|
||||
title="Create site group"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createGroupMutation.mutate({ name: newGroupName });
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{createGroupError && <Alert variant="error">{createGroupError}</Alert>}
|
||||
|
||||
<FormField label="Group name">
|
||||
<Input
|
||||
id="group-name"
|
||||
type="text"
|
||||
required
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="e.g. Steve Madden"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCreateGroup(false);
|
||||
setNewGroupName('');
|
||||
setCreateGroupError('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createGroupMutation.isPending}>
|
||||
{createGroupMutation.isPending ? 'Creating...' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group section component ───────────────────────────────────────── */
|
||||
|
||||
function GroupSection({
|
||||
group,
|
||||
sites,
|
||||
allGroups,
|
||||
groupId,
|
||||
onDelete,
|
||||
onRemoveSite,
|
||||
onMoveSite,
|
||||
}: {
|
||||
group: SiteGroup;
|
||||
sites: Site[];
|
||||
allGroups: SiteGroup[];
|
||||
groupId: string;
|
||||
onDelete: () => void;
|
||||
onRemoveSite: (siteId: string) => void;
|
||||
onMoveSite: (siteId: string, groupId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-heading text-sm font-semibold text-foreground">{group.name}</h2>
|
||||
<Badge variant="neutral">
|
||||
{sites.length} {sites.length === 1 ? 'site' : 'sites'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={`/groups/${groupId}`}
|
||||
className="text-xs font-medium text-copper hover:text-copper/80"
|
||||
>
|
||||
Group defaults
|
||||
</Link>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-status-error-fg hover:text-status-error-fg/80"
|
||||
>
|
||||
Delete group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sites.length > 0 ? (
|
||||
<SiteTable
|
||||
sites={sites}
|
||||
groups={allGroups}
|
||||
currentGroupId={group.id}
|
||||
onRemoveFromGroup={onRemoveSite}
|
||||
onAssignGroup={onMoveSite}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="No sites in this group yet" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared site table component ───────────────────────────────────── */
|
||||
|
||||
function SiteTable({
|
||||
sites,
|
||||
groups,
|
||||
currentGroupId,
|
||||
onRemoveFromGroup,
|
||||
onAssignGroup,
|
||||
}: {
|
||||
sites: Site[];
|
||||
groups: SiteGroup[];
|
||||
currentGroupId?: string;
|
||||
onRemoveFromGroup?: (siteId: string) => void;
|
||||
onAssignGroup?: (siteId: string, groupId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
<th className="px-4 py-3 lg:px-6">Domain</th>
|
||||
<th className="px-4 py-3 lg:px-6">Name</th>
|
||||
<th className="px-4 py-3 lg:px-6">Status</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell lg:px-6">Created</th>
|
||||
<th className="px-4 py-3 lg:px-6">Group</th>
|
||||
<th className="px-4 py-3 lg:px-6"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sites.map((site: Site) => (
|
||||
<tr key={site.id} className="transition hover:bg-mist">
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground lg:px-6">
|
||||
{site.domain}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary lg:px-6">
|
||||
{site.display_name ?? site.name ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 lg:px-6">
|
||||
<Badge variant={site.is_active ? 'success' : 'neutral'}>
|
||||
{site.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-sm text-text-secondary md:table-cell lg:px-6">
|
||||
{new Date(site.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 lg:px-6">
|
||||
<GroupAssigner
|
||||
site={site}
|
||||
groups={groups}
|
||||
currentGroupId={currentGroupId}
|
||||
onRemove={onRemoveFromGroup}
|
||||
onAssign={onAssignGroup}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right lg:px-6">
|
||||
<Link
|
||||
to={`/sites/${site.id}`}
|
||||
className="text-sm font-medium text-copper hover:text-copper/80"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card layout */}
|
||||
<div className="divide-y divide-border sm:hidden">
|
||||
{sites.map((site: Site) => (
|
||||
<div key={site.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{site.domain}</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{site.display_name ?? site.name ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={site.is_active ? 'success' : 'neutral'} className="ml-2 shrink-0">
|
||||
{site.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<GroupAssigner
|
||||
site={site}
|
||||
groups={groups}
|
||||
currentGroupId={currentGroupId}
|
||||
onRemove={onRemoveFromGroup}
|
||||
onAssign={onAssignGroup}
|
||||
/>
|
||||
<Link
|
||||
to={`/sites/${site.id}`}
|
||||
className="text-sm font-medium text-copper hover:text-copper/80"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group assigner inline component ─────────────────────────────── */
|
||||
|
||||
function GroupAssigner({
|
||||
site,
|
||||
groups,
|
||||
currentGroupId,
|
||||
onRemove,
|
||||
onAssign,
|
||||
}: {
|
||||
site: Site;
|
||||
groups: SiteGroup[];
|
||||
currentGroupId?: string;
|
||||
onRemove?: (siteId: string) => void;
|
||||
onAssign?: (siteId: string, groupId: string) => void;
|
||||
}) {
|
||||
// Available groups to move to (exclude current group)
|
||||
const otherGroups = groups.filter((g) => g.id !== currentGroupId);
|
||||
|
||||
if (currentGroupId && onRemove) {
|
||||
// Site is in a group — show remove + move options
|
||||
return (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '__remove__') {
|
||||
onRemove(site.id);
|
||||
} else if (val && onAssign) {
|
||||
onAssign(site.id, val);
|
||||
}
|
||||
}}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-text-secondary outline-none focus:border-copper"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Move...
|
||||
</option>
|
||||
<option value="__remove__">Remove from group</option>
|
||||
{otherGroups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
Move to {g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentGroupId && groups.length > 0 && onAssign) {
|
||||
// Site is ungrouped — show assign options
|
||||
return (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value && onAssign) {
|
||||
onAssign(site.id, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-text-secondary outline-none focus:border-copper"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Add to group...
|
||||
</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-xs text-text-tertiary">—</span>;
|
||||
}
|
||||
58
apps/admin-ui/src/services/analytics.ts
Normal file
58
apps/admin-ui/src/services/analytics.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: Record<string, unknown>[];
|
||||
}
|
||||
}
|
||||
|
||||
/** Push an event to the GTM dataLayer. */
|
||||
function pushEvent(event: string, data?: Record<string, unknown>): void {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({ event, ...data });
|
||||
}
|
||||
|
||||
/** Initialise analytics with user and org context. Called once after auth. */
|
||||
export function initAnalytics(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
organisation_id: string;
|
||||
full_name: string | null;
|
||||
}): void {
|
||||
pushEvent('user_identified', {
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
user_role: user.role,
|
||||
org_id: user.organisation_id,
|
||||
user_name: user.full_name ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Track a page view. */
|
||||
export function trackPageView(path: string, title?: string): void {
|
||||
pushEvent('page_view', { page_path: path, page_title: title });
|
||||
}
|
||||
|
||||
/** Track auth events (login, logout). */
|
||||
export function trackAuthEvent(
|
||||
action: 'login' | 'logout',
|
||||
userId?: string,
|
||||
): void {
|
||||
pushEvent('auth_event', { auth_action: action, user_id: userId });
|
||||
}
|
||||
|
||||
/** Track config changes (site config saved, org config updated, etc.). */
|
||||
export function trackConfigChange(
|
||||
changeType: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
pushEvent('config_change', { change_type: changeType, ...details });
|
||||
}
|
||||
|
||||
/** Track feature usage (banner preview, compliance check, scan triggered, etc.). */
|
||||
export function trackFeatureUsage(
|
||||
feature: string,
|
||||
action: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
pushEvent('feature_usage', { feature, feature_action: action, ...details });
|
||||
}
|
||||
60
apps/admin-ui/src/stores/auth.ts
Normal file
60
apps/admin-ui/src/stores/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getMe, login as loginApi } from '../api/auth';
|
||||
import { initAnalytics, trackAuthEvent } from '../services/analytics';
|
||||
import type { User } from '../types/api';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loadUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isAuthenticated: !!localStorage.getItem('access_token'),
|
||||
isLoading: false,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const tokens = await loginApi(email, password);
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
const user = await getMe();
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
initAnalytics(user);
|
||||
trackAuthEvent('login', user.id);
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
const { user } = useAuthStore.getState();
|
||||
trackAuthEvent('logout', user?.id);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
if (!localStorage.getItem('access_token')) {
|
||||
set({ isAuthenticated: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await getMe();
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
initAnalytics(user);
|
||||
} catch {
|
||||
localStorage.removeItem('access_token');
|
||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
45
apps/admin-ui/src/test/App.test.tsx
Normal file
45
apps/admin-ui/src/test/App.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../App';
|
||||
|
||||
// Mock extension discovery to avoid loading EE modules in tests
|
||||
vi.mock('../extensions/registry', () => ({
|
||||
discoverExtensions: vi.fn(() => Promise.resolve()),
|
||||
getSiteDetailTabs: vi.fn(() => []),
|
||||
getPages: vi.fn(() => []),
|
||||
getNavItems: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock the auth store to control auth state
|
||||
vi.mock('../stores/auth', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
loadUser: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the login page when not authenticated', () => {
|
||||
render(<App />);
|
||||
// ConsentOS wordmark renders Consent + OS as two spans for two-tone colour
|
||||
expect(screen.getByText('Consent')).toBeInTheDocument();
|
||||
expect(screen.getByText('OS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sign in to manage your consent platform')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email and password fields on login page', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByLabelText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the sign in button', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
207
apps/admin-ui/src/test/BannerBuilderTab.test.tsx
Normal file
207
apps/admin-ui/src/test/BannerBuilderTab.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import BannerBuilderTab from '../components/BannerBuilderTab';
|
||||
import type { BannerConfig } from '../types/api';
|
||||
|
||||
const mockOnSave = vi.fn(() => Promise.resolve({}));
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_CONFIG: { banner_config: BannerConfig | null } = {
|
||||
banner_config: null,
|
||||
};
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
configQueryKey: ['sites', 'site-1', 'config'],
|
||||
config: BASE_CONFIG,
|
||||
onSave: mockOnSave,
|
||||
};
|
||||
|
||||
describe('BannerBuilderTab', () => {
|
||||
it('renders the builder with default state', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('banner-builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Display mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Theme')).toBeInTheDocument();
|
||||
expect(screen.getByText('Layout')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all display mode buttons', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Bottom banner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Top banner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Overlay (modal)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Corner popup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the preview iframe', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
const preview = screen.getByTestId('banner-preview');
|
||||
const iframe = within(preview).getByTitle('Banner preview');
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe.tagName).toBe('IFRAME');
|
||||
});
|
||||
|
||||
it('renders viewport toggle buttons', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Desktop')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mobile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles mobile viewport width', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
const mobileBtn = screen.getByText('Mobile');
|
||||
fireEvent.click(mobileBtn);
|
||||
|
||||
const iframe = screen.getByTitle('Banner preview');
|
||||
expect(iframe).toHaveStyle({ width: '375px' });
|
||||
});
|
||||
|
||||
it('renders layout toggle checkboxes', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show 'Reject all' button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Show 'Manage preferences'")).toBeInTheDocument();
|
||||
expect(screen.getByText('Show close button')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show cookie count')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows logo URL field when logo toggle is enabled', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
// Logo is off by default — URL field should not be visible
|
||||
expect(screen.queryByPlaceholderText('https://example.com/logo.svg')).not.toBeInTheDocument();
|
||||
|
||||
// Enable logo
|
||||
const logoCheckbox = screen.getByText('Show logo').closest('label')!.querySelector('input')!;
|
||||
fireEvent.click(logoCheckbox);
|
||||
|
||||
expect(screen.getByPlaceholderText('https://example.com/logo.svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders button style toggle', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default button style')).toBeInTheDocument();
|
||||
// Multiple "Filled"/"Outline" buttons exist (default + per-button editors)
|
||||
expect(screen.getAllByText('Filled').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Outline').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders font selector', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Font')).toBeInTheDocument();
|
||||
expect(screen.getByText('System default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders save button', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Save banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads existing banner config values', () => {
|
||||
const configWithBanner = {
|
||||
banner_config: {
|
||||
displayMode: 'overlay' as const,
|
||||
primaryColour: '#ff0000',
|
||||
backgroundColour: '#000000',
|
||||
textColour: '#ffffff',
|
||||
buttonStyle: 'outline' as const,
|
||||
fontFamily: 'Georgia, serif',
|
||||
borderRadius: 12,
|
||||
showRejectAll: false,
|
||||
showManagePreferences: true,
|
||||
showCloseButton: true,
|
||||
showLogo: true,
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
showCookieCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} config={configWithBanner} />,
|
||||
);
|
||||
|
||||
// Check that the close button toggle is checked
|
||||
const closeLabel = screen.getByText('Show close button').closest('label')!;
|
||||
const closeCheckbox = closeLabel.querySelector('input') as HTMLInputElement;
|
||||
expect(closeCheckbox.checked).toBe(true);
|
||||
|
||||
// Logo URL field should be visible since showLogo is true
|
||||
expect(screen.getByPlaceholderText('https://example.com/logo.svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls save mutation when save button is clicked', async () => {
|
||||
mockOnSave.mockClear();
|
||||
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
const saveBtn = screen.getByText('Save banner');
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
banner_config: expect.objectContaining({
|
||||
primaryColour: '#2563eb',
|
||||
backgroundColour: '#ffffff',
|
||||
textColour: '#1a1a2e',
|
||||
displayMode: 'bottom_banner',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('changes display mode when mode button is clicked', () => {
|
||||
renderWithProviders(
|
||||
<BannerBuilderTab {...DEFAULT_PROPS} />,
|
||||
);
|
||||
|
||||
const overlayBtn = screen.getByText('Overlay (modal)');
|
||||
fireEvent.click(overlayBtn);
|
||||
|
||||
// Overlay button should now be active (bg-primary)
|
||||
expect(overlayBtn.className).toContain('bg-primary');
|
||||
});
|
||||
});
|
||||
336
apps/admin-ui/src/test/BannerPreview.test.tsx
Normal file
336
apps/admin-ui/src/test/BannerPreview.test.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import BannerPreview from '../components/BannerPreview';
|
||||
import type { BannerConfig } from '../types/api';
|
||||
|
||||
const DEFAULT_CONFIG: BannerConfig = {
|
||||
primaryColour: '#2563eb',
|
||||
backgroundColour: '#ffffff',
|
||||
textColour: '#1a1a2e',
|
||||
buttonStyle: 'filled',
|
||||
fontFamily: 'system-ui',
|
||||
borderRadius: 6,
|
||||
showRejectAll: true,
|
||||
showManagePreferences: true,
|
||||
showCloseButton: false,
|
||||
showLogo: false,
|
||||
showCookieCount: false,
|
||||
};
|
||||
|
||||
describe('BannerPreview', () => {
|
||||
it('renders the preview container', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('banner-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an iframe with srcdoc', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = screen.getByTitle('Banner preview') as HTMLIFrameElement;
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe.getAttribute('srcdoc')).toBeTruthy();
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
});
|
||||
|
||||
it('includes banner text in srcdoc', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = screen.getByTitle('Banner preview') as HTMLIFrameElement;
|
||||
const srcdoc = iframe.getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('We use cookies');
|
||||
expect(srcdoc).toContain('Accept all');
|
||||
expect(srcdoc).toContain('Reject all');
|
||||
expect(srcdoc).toContain('Manage preferences');
|
||||
});
|
||||
|
||||
it('applies theme colours to srcdoc', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, primaryColour: '#ff0000', backgroundColour: '#111111' }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('#ff0000');
|
||||
expect(srcdoc).toContain('#111111');
|
||||
});
|
||||
|
||||
it('hides reject all button when showRejectAll is false', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showRejectAll: false }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).not.toContain('Reject all');
|
||||
});
|
||||
|
||||
it('hides manage preferences when showManagePreferences is false', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showManagePreferences: false }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).not.toContain('Manage preferences');
|
||||
});
|
||||
|
||||
it('shows close button when enabled', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showCloseButton: true }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('cmp-close');
|
||||
});
|
||||
|
||||
it('does not show close button element when disabled', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showCloseButton: false }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
// The CSS class may still exist in styles, but the button element should not be rendered
|
||||
expect(srcdoc).not.toContain('<button class="cmp-close"');
|
||||
});
|
||||
|
||||
it('shows cookie count when enabled', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showCookieCount: true }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('12 cookies used on this site');
|
||||
});
|
||||
|
||||
it('shows logo when configured', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, showLogo: true, logoUrl: 'https://example.com/logo.svg' }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('cmp-logo');
|
||||
expect(srcdoc).toContain('https://example.com/logo.svg');
|
||||
});
|
||||
|
||||
it('includes privacy policy link when URL provided', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl="https://example.com/privacy"
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('Privacy Policy');
|
||||
});
|
||||
|
||||
it('uses mobile width when viewport is mobile', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="mobile"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = screen.getByTitle('Banner preview');
|
||||
expect(iframe).toHaveStyle({ width: '375px' });
|
||||
});
|
||||
|
||||
it('uses full width for desktop viewport', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const iframe = screen.getByTitle('Banner preview');
|
||||
expect(iframe).toHaveStyle({ width: '100%' });
|
||||
});
|
||||
|
||||
it('applies overlay positioning styles', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="overlay"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('transform: translate(-50%, -50%)');
|
||||
expect(srcdoc).toContain('cmp-overlay-bg');
|
||||
});
|
||||
|
||||
it('applies corner popup positioning styles', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="corner_popup"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('bottom: 20px');
|
||||
expect(srcdoc).toContain('right: 20px');
|
||||
expect(srcdoc).toContain('width: 380px');
|
||||
});
|
||||
|
||||
it('applies top banner positioning', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="top_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('top: 0');
|
||||
});
|
||||
|
||||
it('applies border radius to buttons', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, borderRadius: 12 }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('border-radius: 12px');
|
||||
});
|
||||
|
||||
it('applies outline button style', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, buttonStyle: 'outline' }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('background: transparent');
|
||||
});
|
||||
|
||||
it('applies custom font family', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{ ...DEFAULT_CONFIG, fontFamily: "'Inter', sans-serif" }}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain("'Inter', sans-serif");
|
||||
});
|
||||
|
||||
it('includes category preferences section', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={DEFAULT_CONFIG}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('Necessary');
|
||||
expect(srcdoc).toContain('Functional');
|
||||
expect(srcdoc).toContain('Analytics');
|
||||
expect(srcdoc).toContain('Marketing');
|
||||
expect(srcdoc).toContain('Personalisation');
|
||||
expect(srcdoc).toContain('Save preferences');
|
||||
});
|
||||
|
||||
it('escapes HTML in logo URL', () => {
|
||||
render(
|
||||
<BannerPreview
|
||||
bannerConfig={{
|
||||
...DEFAULT_CONFIG,
|
||||
showLogo: true,
|
||||
logoUrl: 'https://example.com/logo.svg?a=1&b=2',
|
||||
}}
|
||||
displayMode="bottom_banner"
|
||||
viewport="desktop"
|
||||
privacyPolicyUrl={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const srcdoc = (screen.getByTitle('Banner preview') as HTMLIFrameElement).getAttribute('srcdoc')!;
|
||||
expect(srcdoc).toContain('&');
|
||||
expect(srcdoc).not.toContain('?a=1&b=2"');
|
||||
});
|
||||
});
|
||||
299
apps/admin-ui/src/test/ComplianceDashboardPage.test.tsx
Normal file
299
apps/admin-ui/src/test/ComplianceDashboardPage.test.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import ComplianceDashboardPage from '../pages/ComplianceDashboardPage';
|
||||
import type { ComplianceScoreSummary, ComplianceScoreTrendResponse, Site } from '../types/api';
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockListSites = vi.fn<() => Promise<Site[]>>();
|
||||
const mockGetSummary = vi.fn<() => Promise<ComplianceScoreSummary>>();
|
||||
const mockGetTrend = vi.fn<() => Promise<ComplianceScoreTrendResponse>>();
|
||||
|
||||
vi.mock('../api/sites', () => ({
|
||||
listSites: (...args: unknown[]) => mockListSites(...(args as [])),
|
||||
}));
|
||||
|
||||
vi.mock('../api/compliance-scores', () => ({
|
||||
getComplianceScoreSummary: (...args: unknown[]) => mockGetSummary(...(args as [])),
|
||||
getComplianceScoreTrend: (...args: unknown[]) => mockGetTrend(...(args as [])),
|
||||
}));
|
||||
|
||||
// Mock Recharts to avoid canvas rendering issues in jsdom
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="line-chart">{children}</div>
|
||||
),
|
||||
Line: () => <div data-testid="line" />,
|
||||
XAxis: () => <div data-testid="x-axis" />,
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
||||
Tooltip: () => <div data-testid="tooltip" />,
|
||||
Legend: () => <div data-testid="legend" />,
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ComplianceDashboardPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_SITE: Site = {
|
||||
id: 'site-1',
|
||||
organisation_id: 'org-1',
|
||||
domain: 'example.com',
|
||||
name: 'Example',
|
||||
display_name: 'Example Site',
|
||||
is_active: true,
|
||||
site_group_id: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const TEST_SUMMARY: ComplianceScoreSummary = {
|
||||
site_id: 'site-1',
|
||||
overall_score: 85,
|
||||
frameworks: [
|
||||
{
|
||||
id: 'score-1',
|
||||
site_id: 'site-1',
|
||||
framework: 'gdpr',
|
||||
score: 80,
|
||||
status: 'partial',
|
||||
critical_count: 1,
|
||||
warning_count: 1,
|
||||
info_count: 0,
|
||||
issues: [
|
||||
{
|
||||
rule_id: 'gdpr_reject_button',
|
||||
severity: 'critical',
|
||||
message: 'Reject button not as prominent as accept.',
|
||||
recommendation: 'Add a clearly visible reject button.',
|
||||
},
|
||||
{
|
||||
rule_id: 'gdpr_privacy_policy',
|
||||
severity: 'warning',
|
||||
message: 'Privacy policy link missing.',
|
||||
recommendation: 'Add a privacy policy URL.',
|
||||
},
|
||||
],
|
||||
scanned_at: '2026-03-10T04:00:00Z',
|
||||
created_at: '2026-03-10T04:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'score-2',
|
||||
site_id: 'site-1',
|
||||
framework: 'ccpa',
|
||||
score: 90,
|
||||
status: 'partial',
|
||||
critical_count: 0,
|
||||
warning_count: 2,
|
||||
info_count: 0,
|
||||
issues: [],
|
||||
scanned_at: '2026-03-10T04:00:00Z',
|
||||
created_at: '2026-03-10T04:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TEST_TREND: ComplianceScoreTrendResponse = {
|
||||
site_id: 'site-1',
|
||||
framework: null,
|
||||
data_points: [
|
||||
{ framework: 'gdpr', score: 75, scanned_at: '2026-03-08T04:00:00Z' },
|
||||
{ framework: 'gdpr', score: 80, scanned_at: '2026-03-09T04:00:00Z' },
|
||||
{ framework: 'gdpr', score: 80, scanned_at: '2026-03-10T04:00:00Z' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ComplianceDashboardPage', () => {
|
||||
it('shows empty state when no sites exist', async () => {
|
||||
mockListSites.mockResolvedValue([]);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No sites configured. Add a site first.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the dashboard heading', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Compliance Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the site selector with the site name', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getByText('Example Site')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the overall compliance score', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Overall Compliance')).toBeInTheDocument();
|
||||
// Score badge shows 85
|
||||
expect(screen.getByText('85')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows per-framework scores', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('GDPR').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('CCPA/CPRA').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('80')).toBeInTheDocument();
|
||||
expect(screen.getByText('90')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the trend chart section', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Score Trends')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows date range selector buttons', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('7 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('30 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('90 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 months')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows issues table with framework and severity filters', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All frameworks')).toBeInTheDocument();
|
||||
expect(screen.getByText('All severities')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows issue details when issue row is present', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Reject button not as prominent as accept.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows export buttons', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Export JSON')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export CSV')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty compliance data message when no scores exist', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue({
|
||||
site_id: 'site-1',
|
||||
overall_score: 100,
|
||||
frameworks: [],
|
||||
});
|
||||
mockGetTrend.mockResolvedValue({ site_id: 'site-1', framework: null, data_points: [] });
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('No compliance scores recorded yet. Scores are computed daily.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands issue recommendation on click', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Reject button not as prominent as accept.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the row to expand
|
||||
fireEvent.click(screen.getByText('Reject button not as prominent as accept.'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recommendation:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add a clearly visible reject button.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows critical/warning counts in overview', async () => {
|
||||
mockListSites.mockResolvedValue([TEST_SITE]);
|
||||
mockGetSummary.mockResolvedValue(TEST_SUMMARY);
|
||||
mockGetTrend.mockResolvedValue(TEST_TREND);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 critical')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 warning')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
apps/admin-ui/src/test/SiteConfigTab.test.tsx
Normal file
264
apps/admin-ui/src/test/SiteConfigTab.test.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SiteConfigTab from '../components/SiteConfigTab';
|
||||
import type { SiteConfig } from '../types/api';
|
||||
|
||||
vi.mock('../api/sites', () => ({
|
||||
updateSiteConfig: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_CONFIG: SiteConfig = {
|
||||
id: 'cfg-1',
|
||||
site_id: 'site-1',
|
||||
blocking_mode: 'opt_in',
|
||||
regional_modes: null,
|
||||
tcf_enabled: false,
|
||||
gpp_enabled: true,
|
||||
gpp_supported_apis: ['usnat'],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
|
||||
gpc_global_honour: false,
|
||||
gcm_enabled: true,
|
||||
gcm_default: null,
|
||||
shopify_privacy_enabled: false,
|
||||
banner_config: null,
|
||||
privacy_policy_url: null,
|
||||
terms_url: null,
|
||||
consent_expiry_days: 365,
|
||||
scan_enabled: true,
|
||||
scan_frequency_hours: 168,
|
||||
scan_max_pages: 50,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('SiteConfigTab', () => {
|
||||
it('renders consent settings section', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Consent settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blocking mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Consent expiry (days)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Privacy policy URL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders standards section with TCF and GCM toggles', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Standards & integrations')).toBeInTheDocument();
|
||||
expect(screen.getByText('IAB TCF v2.2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Google Consent Mode v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GPP section with enable toggle', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('IAB Global Privacy Platform (GPP)'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Enable GPP')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows GPP supported sections when GPP is enabled', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Supported sections')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('US National Privacy (Section 7)'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('US California — CCPA/CPRA (Section 8)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides GPP supported sections when GPP is disabled', () => {
|
||||
const config = { ...BASE_CONFIG, gpp_enabled: false };
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={config} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Supported sections')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GPC section with detect toggle', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Global Privacy Control (GPC)'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Detect GPC signal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows GPC jurisdiction list when GPC is enabled', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('California (CCPA/CPRA)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Colorado (CPA)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connecticut (CTDPA)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Texas (TDPSA)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Montana (MTCDPA)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides GPC jurisdictions when GPC is disabled', () => {
|
||||
const config = { ...BASE_CONFIG, gpc_enabled: false };
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={config} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText('California (CCPA/CPRA)'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Honour globally')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows honour globally toggle when GPC is enabled', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Honour globally')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides jurisdiction list when global honour is enabled', () => {
|
||||
const config = { ...BASE_CONFIG, gpc_global_honour: true };
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={config} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Honour globally')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Jurisdictions where GPC is legally required'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles GPP section checkbox', () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
// usnat should be checked by default
|
||||
const usnatLabel = screen
|
||||
.getByText('US National Privacy (Section 7)')
|
||||
.closest('label')!;
|
||||
const usnatCheckbox = usnatLabel.querySelector('input') as HTMLInputElement;
|
||||
expect(usnatCheckbox.checked).toBe(true);
|
||||
|
||||
// usca should be unchecked
|
||||
const uscaLabel = screen
|
||||
.getByText('US California — CCPA/CPRA (Section 8)')
|
||||
.closest('label')!;
|
||||
const uscaCheckbox = uscaLabel.querySelector('input') as HTMLInputElement;
|
||||
expect(uscaCheckbox.checked).toBe(false);
|
||||
|
||||
// Toggle usca on
|
||||
fireEvent.click(uscaCheckbox);
|
||||
expect(uscaCheckbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('submits GPP/GPC configuration', async () => {
|
||||
const sitesApi = await import('../api/sites');
|
||||
const spy = vi.mocked(sitesApi.updateSiteConfig);
|
||||
spy.mockClear();
|
||||
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
const saveBtn = screen.getByText('Save configuration');
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'site-1',
|
||||
expect.objectContaining({
|
||||
gpp_enabled: true,
|
||||
gpp_supported_apis: ['usnat'],
|
||||
gpc_enabled: true,
|
||||
gpc_jurisdictions: ['US-CA', 'US-CO', 'US-CT', 'US-TX', 'US-MT'],
|
||||
gpc_global_honour: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('nulls gpp_supported_apis when GPP is disabled on submit', async () => {
|
||||
const sitesApi = await import('../api/sites');
|
||||
const spy = vi.mocked(sitesApi.updateSiteConfig);
|
||||
spy.mockClear();
|
||||
|
||||
const config = { ...BASE_CONFIG, gpp_enabled: false };
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={config} />,
|
||||
);
|
||||
|
||||
const saveBtn = screen.getByText('Save configuration');
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'site-1',
|
||||
expect.objectContaining({
|
||||
gpp_enabled: false,
|
||||
gpp_supported_apis: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('nulls gpc_jurisdictions when GPC is disabled on submit', async () => {
|
||||
const sitesApi = await import('../api/sites');
|
||||
const spy = vi.mocked(sitesApi.updateSiteConfig);
|
||||
spy.mockClear();
|
||||
|
||||
const config = { ...BASE_CONFIG, gpc_enabled: false };
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={config} />,
|
||||
);
|
||||
|
||||
const saveBtn = screen.getByText('Save configuration');
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'site-1',
|
||||
expect.objectContaining({
|
||||
gpc_enabled: false,
|
||||
gpc_jurisdictions: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders save button and shows success message', async () => {
|
||||
renderWithProviders(
|
||||
<SiteConfigTab siteId="site-1" config={BASE_CONFIG} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Save configuration')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
193
apps/admin-ui/src/test/SiteScannerTab.test.tsx
Normal file
193
apps/admin-ui/src/test/SiteScannerTab.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import SiteScannerTab from '../components/SiteScannerTab';
|
||||
import type { ScanDiff, ScanJob, ScanJobDetail } from '../types/api';
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockListScans = vi.fn<() => Promise<ScanJob[]>>();
|
||||
const mockTriggerScan = vi.fn<() => Promise<ScanJob>>();
|
||||
const mockGetScan = vi.fn<() => Promise<ScanJobDetail>>();
|
||||
const mockGetScanDiff = vi.fn<() => Promise<ScanDiff>>();
|
||||
|
||||
vi.mock('../api/scanner', () => ({
|
||||
listScans: (...args: unknown[]) => mockListScans(...(args as [])),
|
||||
triggerScan: (...args: unknown[]) => mockTriggerScan(...(args as [])),
|
||||
getScan: (...args: unknown[]) => mockGetScan(...(args as [])),
|
||||
getScanDiff: (...args: unknown[]) => mockGetScanDiff(...(args as [])),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<SiteScannerTab siteId="site-1" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_SCAN: ScanJob = {
|
||||
id: 'scan-1',
|
||||
site_id: 'site-1',
|
||||
status: 'completed',
|
||||
trigger: 'manual',
|
||||
pages_scanned: 5,
|
||||
pages_total: 10,
|
||||
cookies_found: 3,
|
||||
error_message: null,
|
||||
started_at: '2026-03-10T10:00:00Z',
|
||||
completed_at: '2026-03-10T10:05:00Z',
|
||||
created_at: '2026-03-10T10:00:00Z',
|
||||
updated_at: '2026-03-10T10:05:00Z',
|
||||
};
|
||||
|
||||
const TEST_SCAN_DETAIL: ScanJobDetail = {
|
||||
...TEST_SCAN,
|
||||
results: [
|
||||
{
|
||||
id: 'r-1',
|
||||
scan_job_id: 'scan-1',
|
||||
page_url: 'https://example.com/',
|
||||
cookie_name: '_ga',
|
||||
cookie_domain: '.example.com',
|
||||
storage_type: 'cookie',
|
||||
attributes: null,
|
||||
script_source: 'https://www.googletagmanager.com/gtag/js',
|
||||
auto_category: 'analytics',
|
||||
initiator_chain: [
|
||||
'https://example.com/',
|
||||
'https://www.googletagmanager.com/gtm.js',
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
],
|
||||
found_at: '2026-03-10T10:03:00Z',
|
||||
created_at: '2026-03-10T10:03:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r-2',
|
||||
scan_job_id: 'scan-1',
|
||||
page_url: 'https://example.com/',
|
||||
cookie_name: 'session',
|
||||
cookie_domain: 'example.com',
|
||||
storage_type: 'cookie',
|
||||
attributes: null,
|
||||
script_source: null,
|
||||
auto_category: null,
|
||||
initiator_chain: null,
|
||||
found_at: '2026-03-10T10:03:00Z',
|
||||
created_at: '2026-03-10T10:03:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TEST_DIFF: ScanDiff = {
|
||||
current_scan_id: 'scan-1',
|
||||
previous_scan_id: null,
|
||||
new_cookies: [],
|
||||
removed_cookies: [],
|
||||
changed_cookies: [],
|
||||
total_new: 0,
|
||||
total_removed: 0,
|
||||
total_changed: 0,
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SiteScannerTab', () => {
|
||||
it('shows empty state when no scans exist', async () => {
|
||||
mockListScans.mockResolvedValue([]);
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No scans yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders scan history table', async () => {
|
||||
mockListScans.mockResolvedValue([TEST_SCAN]);
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('completed')).toBeInTheDocument();
|
||||
expect(screen.getByText('manual')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows View Diff button for completed scans', async () => {
|
||||
mockListScans.mockResolvedValue([TEST_SCAN]);
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('View Diff')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows initiator chains when expanding a completed scan', async () => {
|
||||
mockListScans.mockResolvedValue([TEST_SCAN]);
|
||||
mockGetScanDiff.mockResolvedValue(TEST_DIFF);
|
||||
mockGetScan.mockResolvedValue(TEST_SCAN_DETAIL);
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('View Diff')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('View Diff'));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show initiator chains section with 1 cookie (the one with a chain)
|
||||
expect(screen.getByText('Initiator Chains (1 cookies)')).toBeInTheDocument();
|
||||
// Should show the cookie name
|
||||
expect(screen.getByText('_ga')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no initiator chains message when none detected', async () => {
|
||||
mockListScans.mockResolvedValue([TEST_SCAN]);
|
||||
mockGetScanDiff.mockResolvedValue(TEST_DIFF);
|
||||
mockGetScan.mockResolvedValue({
|
||||
...TEST_SCAN,
|
||||
results: [
|
||||
{
|
||||
id: 'r-2',
|
||||
scan_job_id: 'scan-1',
|
||||
page_url: 'https://example.com/',
|
||||
cookie_name: 'session',
|
||||
cookie_domain: 'example.com',
|
||||
storage_type: 'cookie',
|
||||
attributes: null,
|
||||
script_source: null,
|
||||
auto_category: null,
|
||||
initiator_chain: null,
|
||||
found_at: '2026-03-10T10:03:00Z',
|
||||
created_at: '2026-03-10T10:03:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('View Diff')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('View Diff'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No initiator chains detected in this scan.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/admin-ui/src/test/analytics.test.ts
Normal file
90
apps/admin-ui/src/test/analytics.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
initAnalytics,
|
||||
trackPageView,
|
||||
trackAuthEvent,
|
||||
trackConfigChange,
|
||||
trackFeatureUsage,
|
||||
} from '../services/analytics';
|
||||
|
||||
describe('analytics service', () => {
|
||||
beforeEach(() => {
|
||||
window.dataLayer = [];
|
||||
});
|
||||
|
||||
it('pushes user_identified event on initAnalytics', () => {
|
||||
initAnalytics({
|
||||
id: 'u1',
|
||||
email: 'test@example.com',
|
||||
role: 'admin',
|
||||
organisation_id: 'org1',
|
||||
full_name: 'Test User',
|
||||
});
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({
|
||||
event: 'user_identified',
|
||||
user_id: 'u1',
|
||||
user_email: 'test@example.com',
|
||||
user_role: 'admin',
|
||||
org_id: 'org1',
|
||||
user_name: 'Test User',
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes page_view event on trackPageView', () => {
|
||||
trackPageView('/sites', 'Sites');
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({
|
||||
event: 'page_view',
|
||||
page_path: '/sites',
|
||||
page_title: 'Sites',
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes auth_event on trackAuthEvent', () => {
|
||||
trackAuthEvent('login', 'u1');
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({
|
||||
event: 'auth_event',
|
||||
auth_action: 'login',
|
||||
user_id: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes config_change event on trackConfigChange', () => {
|
||||
trackConfigChange('site_config', { site_id: 's1' });
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({
|
||||
event: 'config_change',
|
||||
change_type: 'site_config',
|
||||
site_id: 's1',
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes feature_usage event on trackFeatureUsage', () => {
|
||||
trackFeatureUsage('scan', 'trigger', { site_id: 's1' });
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({
|
||||
event: 'feature_usage',
|
||||
feature: 'scan',
|
||||
feature_action: 'trigger',
|
||||
site_id: 's1',
|
||||
});
|
||||
});
|
||||
|
||||
it('initialises dataLayer if not present', () => {
|
||||
// @ts-expect-error — testing uninitialised state
|
||||
delete window.dataLayer;
|
||||
|
||||
trackPageView('/test');
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1);
|
||||
expect(window.dataLayer[0]).toMatchObject({ event: 'page_view' });
|
||||
});
|
||||
});
|
||||
36
apps/admin-ui/src/test/auth-store.test.ts
Normal file
36
apps/admin-ui/src/test/auth-store.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// We test the store logic in isolation
|
||||
describe('auth store', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('starts unauthenticated when no token is stored', async () => {
|
||||
const { useAuthStore } = await import('../stores/auth');
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.user).toBeNull();
|
||||
});
|
||||
|
||||
it('starts authenticated when a token exists in localStorage', async () => {
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
const { useAuthStore } = await import('../stores/auth');
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('logout clears tokens and resets state', async () => {
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
localStorage.setItem('refresh_token', 'test-refresh');
|
||||
const { useAuthStore } = await import('../stores/auth');
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
|
||||
expect(localStorage.getItem('access_token')).toBeNull();
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
});
|
||||
});
|
||||
120
apps/admin-ui/src/test/extensions-registry.test.ts
Normal file
120
apps/admin-ui/src/test/extensions-registry.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// We need to isolate each test from the module-level singleton state,
|
||||
// so we re-import the module fresh for each test.
|
||||
|
||||
describe('UI extension registry', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let registry: typeof import('../extensions/registry');
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset module cache to get a clean registry each time
|
||||
const modulePath = '../extensions/registry';
|
||||
// Vitest doesn't natively re-import, so we use dynamic import with cache busting
|
||||
vi.resetModules();
|
||||
registry = await import(modulePath);
|
||||
});
|
||||
|
||||
describe('getSiteDetailTabs', () => {
|
||||
it('returns empty array when no tabs registered', () => {
|
||||
expect(registry.getSiteDetailTabs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns registered tabs sorted by order', () => {
|
||||
const FakeComponent = () => null;
|
||||
registry.registerSiteDetailTab({
|
||||
id: 'tab-b',
|
||||
label: 'Tab B',
|
||||
component: FakeComponent,
|
||||
order: 300,
|
||||
});
|
||||
registry.registerSiteDetailTab({
|
||||
id: 'tab-a',
|
||||
label: 'Tab A',
|
||||
component: FakeComponent,
|
||||
order: 200,
|
||||
});
|
||||
|
||||
const tabs = registry.getSiteDetailTabs();
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[0].id).toBe('tab-a');
|
||||
expect(tabs[1].id).toBe('tab-b');
|
||||
});
|
||||
|
||||
it('does not register duplicate tab ids', () => {
|
||||
const FakeComponent = () => null;
|
||||
registry.registerSiteDetailTab({
|
||||
id: 'dup',
|
||||
label: 'First',
|
||||
component: FakeComponent,
|
||||
});
|
||||
registry.registerSiteDetailTab({
|
||||
id: 'dup',
|
||||
label: 'Second',
|
||||
component: FakeComponent,
|
||||
});
|
||||
|
||||
expect(registry.getSiteDetailTabs()).toHaveLength(1);
|
||||
expect(registry.getSiteDetailTabs()[0].label).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPages', () => {
|
||||
it('returns empty array when no pages registered', () => {
|
||||
expect(registry.getPages()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns registered pages', () => {
|
||||
const FakeComponent = () => null;
|
||||
registry.registerPage({
|
||||
path: '/ee/billing',
|
||||
component: FakeComponent,
|
||||
});
|
||||
|
||||
const pages = registry.getPages();
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0].path).toBe('/ee/billing');
|
||||
});
|
||||
|
||||
it('does not register duplicate paths', () => {
|
||||
const FakeComponent = () => null;
|
||||
registry.registerPage({ path: '/ee/billing', component: FakeComponent });
|
||||
registry.registerPage({ path: '/ee/billing', component: FakeComponent });
|
||||
|
||||
expect(registry.getPages()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNavItems', () => {
|
||||
it('returns empty array when no nav items registered', () => {
|
||||
expect(registry.getNavItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns registered nav items sorted by order', () => {
|
||||
registry.registerNavItem({ path: '/ee/b', label: 'B', order: 300 });
|
||||
registry.registerNavItem({ path: '/ee/a', label: 'A', order: 200 });
|
||||
|
||||
const items = registry.getNavItems();
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].label).toBe('A');
|
||||
expect(items[1].label).toBe('B');
|
||||
});
|
||||
|
||||
it('does not register duplicate paths', () => {
|
||||
registry.registerNavItem({ path: '/ee/a', label: 'A' });
|
||||
registry.registerNavItem({ path: '/ee/a', label: 'A2' });
|
||||
|
||||
expect(registry.getNavItems()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverExtensions', () => {
|
||||
it('does not throw and is callable', () => {
|
||||
// discoverExtensions uses import.meta.glob which is Vite-specific.
|
||||
// In the test environment the EE module may not fully resolve, so
|
||||
// we verify the function exists and is callable rather than
|
||||
// executing the full dynamic import chain.
|
||||
expect(typeof registry.discoverExtensions).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
1
apps/admin-ui/src/test/setup.ts
Normal file
1
apps/admin-ui/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
119
apps/admin-ui/src/test/statistics.test.ts
Normal file
119
apps/admin-ui/src/test/statistics.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { chiSquaredTest, requiredSampleSize } from '../utils/statistics';
|
||||
|
||||
describe('chiSquaredTest', () => {
|
||||
it('returns not_enough_data when total observations below threshold', () => {
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 5, total: 20 },
|
||||
{ successes: 3, total: 15 },
|
||||
]);
|
||||
expect(result.level).toBe('not_enough_data');
|
||||
expect(result.pValue).toBeNull();
|
||||
});
|
||||
|
||||
it('returns not_significant for identical rates', () => {
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 50, total: 100 },
|
||||
{ successes: 50, total: 100 },
|
||||
]);
|
||||
expect(result.level).toBe('not_significant');
|
||||
expect(result.pValue).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it('returns significant for very different rates with large sample', () => {
|
||||
// 80% vs 40% with n=500 each — should be extremely significant
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 400, total: 500 },
|
||||
{ successes: 200, total: 500 },
|
||||
]);
|
||||
expect(result.level).toBe('significant');
|
||||
expect(result.confidence).toBeGreaterThan(95);
|
||||
expect(result.pValue).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it('returns trending for moderate differences', () => {
|
||||
// Find sample sizes that produce ~90-95% confidence
|
||||
// 55% vs 45% with n=200 each
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 110, total: 200 },
|
||||
{ successes: 90, total: 200 },
|
||||
]);
|
||||
// This should be somewhere between not significant and significant
|
||||
expect(result.pValue).not.toBeNull();
|
||||
expect(result.confidence).not.toBeNull();
|
||||
expect(result.confidence!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles three variants', () => {
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 80, total: 100 },
|
||||
{ successes: 60, total: 100 },
|
||||
{ successes: 40, total: 100 },
|
||||
]);
|
||||
expect(result.level).toBe('significant');
|
||||
});
|
||||
|
||||
it('handles zero success rate', () => {
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 0, total: 200 },
|
||||
{ successes: 0, total: 200 },
|
||||
]);
|
||||
expect(result.level).toBe('not_significant');
|
||||
});
|
||||
|
||||
it('handles 100% success rate', () => {
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 200, total: 200 },
|
||||
{ successes: 200, total: 200 },
|
||||
]);
|
||||
expect(result.level).toBe('not_significant');
|
||||
});
|
||||
|
||||
it('correctly identifies known chi-squared value (manual verification)', () => {
|
||||
// With 2 groups: 70/100 vs 50/100
|
||||
// Expected: (120/200)*100 = 60 per group
|
||||
// Chi-sq = (70-60)^2/60 + (30-40)^2/40 + (50-60)^2/60 + (50-40)^2/40
|
||||
// = 100/60 + 100/40 + 100/60 + 100/40
|
||||
// = 1.667 + 2.5 + 1.667 + 2.5 = 8.333
|
||||
// df=1, p-value ≈ 0.0039 → highly significant
|
||||
const result = chiSquaredTest([
|
||||
{ successes: 70, total: 100 },
|
||||
{ successes: 50, total: 100 },
|
||||
]);
|
||||
expect(result.level).toBe('significant');
|
||||
expect(result.pValue!).toBeLessThan(0.01);
|
||||
expect(result.confidence!).toBeGreaterThan(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredSampleSize', () => {
|
||||
it('returns a positive number for valid inputs', () => {
|
||||
const n = requiredSampleSize(0.5, 0.1);
|
||||
expect(n).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(n)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns larger sample for smaller detectable effect', () => {
|
||||
const n5 = requiredSampleSize(0.5, 0.05);
|
||||
const n10 = requiredSampleSize(0.5, 0.1);
|
||||
expect(n5).toBeGreaterThan(n10);
|
||||
});
|
||||
|
||||
it('returns Infinity for zero or negative effect', () => {
|
||||
expect(requiredSampleSize(0.5, 0)).toBe(Infinity);
|
||||
expect(requiredSampleSize(0.5, -0.1)).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('returns 0 for invalid baseline rates', () => {
|
||||
expect(requiredSampleSize(0, 0.1)).toBe(0);
|
||||
expect(requiredSampleSize(1, 0.1)).toBe(0);
|
||||
});
|
||||
|
||||
it('gives reasonable values for typical consent scenarios', () => {
|
||||
// 50% baseline, 5% MDE, 80% power, 5% significance
|
||||
const n = requiredSampleSize(0.5, 0.05);
|
||||
// Expected ≈ 3000-4000 per variant
|
||||
expect(n).toBeGreaterThan(1000);
|
||||
expect(n).toBeLessThan(10000);
|
||||
});
|
||||
});
|
||||
290
apps/admin-ui/src/test/ui-components.test.tsx
Normal file
290
apps/admin-ui/src/test/ui-components.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Button } from "../components/ui/button.tsx";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "../components/ui/card.tsx";
|
||||
import { Badge } from "../components/ui/badge.tsx";
|
||||
import { Input } from "../components/ui/input.tsx";
|
||||
import { Textarea } from "../components/ui/textarea.tsx";
|
||||
import { Select } from "../components/ui/select.tsx";
|
||||
import { FormField } from "../components/ui/form-field.tsx";
|
||||
import { Modal } from "../components/ui/modal.tsx";
|
||||
import { EmptyState } from "../components/ui/empty-state.tsx";
|
||||
import { LoadingState } from "../components/ui/loading-state.tsx";
|
||||
import { Alert } from "../components/ui/alert.tsx";
|
||||
import { MetricCard } from "../components/ui/metric-card.tsx";
|
||||
import { TabGroup } from "../components/ui/tab-group.tsx";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders with text content", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies variant classes", () => {
|
||||
render(<Button variant="destructive">Delete</Button>);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.className).toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("applies size classes", () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
const btn = screen.getByRole("button", { name: "Small" });
|
||||
expect(btn.className).toContain("h-9");
|
||||
});
|
||||
|
||||
it("forwards onClick handler", () => {
|
||||
const handler = vi.fn();
|
||||
render(<Button onClick={handler}>Press</Button>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Press" }));
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Button className="mt-4">Styled</Button>);
|
||||
const btn = screen.getByRole("button", { name: "Styled" });
|
||||
expect(btn.className).toContain("mt-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card", () => {
|
||||
it("renders card with header, title, content, and footer", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>Body</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||
expect(screen.getByText("Footer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders with variant", () => {
|
||||
render(<Badge variant="success">Active</Badge>);
|
||||
const badge = screen.getByText("Active");
|
||||
expect(badge.className).toContain("bg-status-success-bg");
|
||||
});
|
||||
|
||||
it("defaults to neutral variant", () => {
|
||||
render(<Badge>Default</Badge>);
|
||||
const badge = screen.getByText("Default");
|
||||
expect(badge.className).toContain("bg-mist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders an input element", () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards type prop", () => {
|
||||
render(<Input type="email" placeholder="Email" />);
|
||||
expect(screen.getByPlaceholderText("Email")).toHaveAttribute("type", "email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("renders a textarea element", () => {
|
||||
render(<Textarea placeholder="Write here" />);
|
||||
expect(screen.getByPlaceholderText("Write here")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("renders a select element with options", () => {
|
||||
render(
|
||||
<Select defaultValue="b">
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
</Select>,
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormField", () => {
|
||||
it("renders label and children", () => {
|
||||
render(
|
||||
<FormField label="Name">
|
||||
<Input placeholder="Your name" />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Your name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when provided", () => {
|
||||
render(
|
||||
<FormField label="Email" error="Required field">
|
||||
<Input />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText("Required field")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render error paragraph when no error", () => {
|
||||
const { container } = render(
|
||||
<FormField label="Name">
|
||||
<Input />
|
||||
</FormField>,
|
||||
);
|
||||
expect(container.querySelector("p")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal", () => {
|
||||
it("renders when open", () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="Test Modal">
|
||||
<p>Modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Modal content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
render(
|
||||
<Modal open={false} onClose={() => {}} title="Hidden">
|
||||
<p>Hidden content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose on Escape key", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose} title="Closeable">
|
||||
<p>Press escape</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onClose when backdrop is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose} title="Backdrop">
|
||||
<p>Click outside</p>
|
||||
</Modal>,
|
||||
);
|
||||
// The backdrop is the element with aria-hidden="true"
|
||||
const backdrop = document.querySelector("[aria-hidden='true']")!;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders the message", () => {
|
||||
render(<EmptyState message="No items found" />);
|
||||
expect(screen.getByText("No items found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("renders default message", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom message", () => {
|
||||
render(<LoadingState message="Fetching data..." />);
|
||||
expect(screen.getByText("Fetching data...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders with error variant", () => {
|
||||
render(<Alert variant="error">Something went wrong</Alert>);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent("Something went wrong");
|
||||
expect(alert.className).toContain("bg-status-error-bg");
|
||||
});
|
||||
|
||||
it("renders with success variant", () => {
|
||||
render(<Alert variant="success">Saved</Alert>);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.className).toContain("bg-status-success-bg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetricCard", () => {
|
||||
it("renders label and value", () => {
|
||||
render(<MetricCard label="Total cookies" value={142} />);
|
||||
expect(screen.getByText("Total cookies")).toBeInTheDocument();
|
||||
expect(screen.getByText("142")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders comparison when provided", () => {
|
||||
render(
|
||||
<MetricCard
|
||||
label="Consent rate"
|
||||
value="87.2%"
|
||||
comparison={{ previous: "82.1%", direction: "up" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("87.2%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/82\.1%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows up arrow for positive direction", () => {
|
||||
render(
|
||||
<MetricCard
|
||||
label="Rate"
|
||||
value="50%"
|
||||
comparison={{ previous: "40%", direction: "up" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("↑")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows down arrow for negative direction", () => {
|
||||
render(
|
||||
<MetricCard
|
||||
label="Rate"
|
||||
value="30%"
|
||||
comparison={{ previous: "40%", direction: "down" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("↓")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabGroup", () => {
|
||||
const options = [
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
{ value: "month", label: "Month" },
|
||||
];
|
||||
|
||||
it("renders all options", () => {
|
||||
render(<TabGroup options={options} value="day" onChange={() => {}} />);
|
||||
expect(screen.getByText("Day")).toBeInTheDocument();
|
||||
expect(screen.getByText("Week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Month")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onChange with correct value on click", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabGroup options={options} value="day" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("Week"));
|
||||
expect(onChange).toHaveBeenCalledWith("week");
|
||||
});
|
||||
|
||||
it("highlights the active option", () => {
|
||||
render(<TabGroup options={options} value="week" onChange={() => {}} />);
|
||||
const activeBtn = screen.getByText("Week");
|
||||
expect(activeBtn.className).toContain("bg-card");
|
||||
});
|
||||
});
|
||||
675
apps/admin-ui/src/types/api.ts
Normal file
675
apps/admin-ui/src/types/api.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
/** API response types matching the backend Pydantic schemas. */
|
||||
|
||||
export interface Organisation {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
is_active: boolean;
|
||||
organisation_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
organisation_id: string;
|
||||
domain: string;
|
||||
name: string | null;
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
site_group_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteGroup {
|
||||
id: string;
|
||||
organisation_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
site_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteGroupConfig {
|
||||
id: string;
|
||||
site_group_id: string;
|
||||
blocking_mode: 'opt_in' | 'opt_out' | 'informational' | null;
|
||||
regional_modes: Record<string, string> | null;
|
||||
tcf_enabled: boolean | null;
|
||||
tcf_publisher_cc: string | null;
|
||||
gcm_enabled: boolean | null;
|
||||
gcm_default: Record<string, 'granted' | 'denied'> | null;
|
||||
shopify_privacy_enabled: boolean | null;
|
||||
gpp_enabled: boolean | null;
|
||||
gpp_supported_apis: string[] | null;
|
||||
gpc_enabled: boolean | null;
|
||||
gpc_jurisdictions: string[] | null;
|
||||
gpc_global_honour: boolean | null;
|
||||
banner_config: BannerConfig | null;
|
||||
privacy_policy_url: string | null;
|
||||
terms_url: string | null;
|
||||
scan_schedule_cron: string | null;
|
||||
scan_max_pages: number | null;
|
||||
consent_expiry_days: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ConfigSource = 'system' | 'org' | 'group' | 'site';
|
||||
|
||||
export interface ConfigFieldInheritance {
|
||||
resolved_value: unknown;
|
||||
source: ConfigSource;
|
||||
site_value: unknown;
|
||||
group_value: unknown;
|
||||
org_value: unknown;
|
||||
system_value: unknown;
|
||||
}
|
||||
|
||||
export interface ConfigInheritanceResponse {
|
||||
site_id: string;
|
||||
site_group_id: string | null;
|
||||
fields: Record<string, ConfigFieldInheritance>;
|
||||
}
|
||||
|
||||
export interface OrgConfig {
|
||||
id: string;
|
||||
organisation_id: string;
|
||||
blocking_mode: 'opt_in' | 'opt_out' | 'informational' | null;
|
||||
regional_modes: Record<string, string> | null;
|
||||
tcf_enabled: boolean | null;
|
||||
tcf_publisher_cc: string | null;
|
||||
gpp_enabled: boolean | null;
|
||||
gpp_supported_apis: string[] | null;
|
||||
gpc_enabled: boolean | null;
|
||||
gpc_jurisdictions: string[] | null;
|
||||
gpc_global_honour: boolean | null;
|
||||
gcm_enabled: boolean | null;
|
||||
gcm_default: Record<string, 'granted' | 'denied'> | null;
|
||||
shopify_privacy_enabled: boolean | null;
|
||||
banner_config: BannerConfig | null;
|
||||
privacy_policy_url: string | null;
|
||||
terms_url: string | null;
|
||||
scan_schedule_cron: string | null;
|
||||
scan_max_pages: number | null;
|
||||
consent_expiry_days: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
id: string;
|
||||
site_id: string;
|
||||
blocking_mode: 'opt_in' | 'opt_out' | 'informational';
|
||||
regional_modes: Record<string, string> | null;
|
||||
tcf_enabled: boolean;
|
||||
gpp_enabled: boolean;
|
||||
gpp_supported_apis: string[] | null;
|
||||
gpc_enabled: boolean;
|
||||
gpc_jurisdictions: string[] | null;
|
||||
gpc_global_honour: boolean;
|
||||
gcm_enabled: boolean;
|
||||
gcm_default: Record<string, 'granted' | 'denied'> | null;
|
||||
shopify_privacy_enabled: boolean;
|
||||
banner_config: BannerConfig | null;
|
||||
privacy_policy_url: string | null;
|
||||
terms_url: string | null;
|
||||
consent_expiry_days: number;
|
||||
scan_enabled: boolean;
|
||||
scan_frequency_hours: number;
|
||||
scan_max_pages: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ButtonConfig {
|
||||
backgroundColour?: string;
|
||||
textColour?: string;
|
||||
borderColour?: string;
|
||||
style?: 'filled' | 'outline' | 'text';
|
||||
}
|
||||
|
||||
export interface BannerTextConfig {
|
||||
title?: string;
|
||||
description?: string;
|
||||
acceptAll?: string;
|
||||
rejectAll?: string;
|
||||
managePreferences?: string;
|
||||
savePreferences?: string;
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
displayMode?: 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
|
||||
cornerPosition?: 'left' | 'right';
|
||||
primaryColour?: string;
|
||||
backgroundColour?: string;
|
||||
textColour?: string;
|
||||
buttonStyle?: 'filled' | 'outline';
|
||||
fontFamily?: string;
|
||||
customFontUrl?: string;
|
||||
borderRadius?: number;
|
||||
showLogo?: boolean;
|
||||
logoUrl?: string;
|
||||
showRejectAll?: boolean;
|
||||
showManagePreferences?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
showCookieCount?: boolean;
|
||||
acceptButton?: ButtonConfig;
|
||||
rejectButton?: ButtonConfig;
|
||||
manageButton?: ButtonConfig;
|
||||
text?: BannerTextConfig;
|
||||
}
|
||||
|
||||
export interface Translation {
|
||||
id: string;
|
||||
site_id: string;
|
||||
locale: string;
|
||||
strings: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CookieCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
is_essential: boolean;
|
||||
display_order: number;
|
||||
tcf_purpose_ids: number[] | null;
|
||||
gcm_consent_types: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Cookie {
|
||||
id: string;
|
||||
site_id: string;
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
domain: string;
|
||||
storage_type: string;
|
||||
description: string | null;
|
||||
vendor: string | null;
|
||||
review_status: 'pending' | 'approved' | 'rejected';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AllowListEntry {
|
||||
id: string;
|
||||
site_id: string;
|
||||
category_id: string;
|
||||
name_pattern: string;
|
||||
domain_pattern: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface ScanJob {
|
||||
id: string;
|
||||
site_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
trigger: 'manual' | 'scheduled' | 'client_report';
|
||||
pages_scanned: number;
|
||||
pages_total: number | null;
|
||||
cookies_found: number;
|
||||
error_message: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
id: string;
|
||||
scan_job_id: string;
|
||||
page_url: string;
|
||||
cookie_name: string;
|
||||
cookie_domain: string;
|
||||
storage_type: string;
|
||||
attributes: Record<string, unknown> | null;
|
||||
script_source: string | null;
|
||||
auto_category: string | null;
|
||||
initiator_chain: string[] | null;
|
||||
found_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ScanJobDetail extends ScanJob {
|
||||
results: ScanResult[];
|
||||
}
|
||||
|
||||
export interface CookieDiffItem {
|
||||
name: string;
|
||||
domain: string;
|
||||
storage_type: string;
|
||||
diff_status: 'new' | 'removed' | 'changed';
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
export interface ScanDiff {
|
||||
current_scan_id: string;
|
||||
previous_scan_id: string | null;
|
||||
new_cookies: CookieDiffItem[];
|
||||
removed_cookies: CookieDiffItem[];
|
||||
changed_cookies: CookieDiffItem[];
|
||||
total_new: number;
|
||||
total_removed: number;
|
||||
total_changed: number;
|
||||
}
|
||||
|
||||
// ── Cross-domain consent sync ────────────────────────────────────────
|
||||
|
||||
export interface ConsentGroup {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
merge_strategy: 'server_wins' | 'latest_wins';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConsentGroupSite {
|
||||
id: string;
|
||||
domain: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface PublicKey {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
algorithm: 'RS256' | 'ES256';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Compliance ──────────────────────────────────────────────────────
|
||||
|
||||
export type ComplianceFramework = 'gdpr' | 'cnil' | 'ccpa' | 'eprivacy' | 'lgpd';
|
||||
export type ComplianceSeverity = 'critical' | 'warning' | 'info';
|
||||
export type ComplianceStatus = 'compliant' | 'partial' | 'non_compliant';
|
||||
|
||||
export interface ComplianceIssue {
|
||||
rule_id: string;
|
||||
severity: ComplianceSeverity;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export interface FrameworkResult {
|
||||
framework: ComplianceFramework;
|
||||
score: number;
|
||||
status: ComplianceStatus;
|
||||
issues: ComplianceIssue[];
|
||||
rules_checked: number;
|
||||
rules_passed: number;
|
||||
}
|
||||
|
||||
export interface ComplianceCheckResponse {
|
||||
site_id: string;
|
||||
results: FrameworkResult[];
|
||||
overall_score: number;
|
||||
}
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ActionBreakdown {
|
||||
accept_all: number;
|
||||
reject_all: number;
|
||||
custom: number;
|
||||
withdraw: number;
|
||||
}
|
||||
|
||||
export interface CategoryRate {
|
||||
category: string;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface ConsentRatesResponse {
|
||||
site_id: string;
|
||||
total_records: number;
|
||||
consent_rate: number;
|
||||
action_breakdown: ActionBreakdown;
|
||||
category_rates: CategoryRate[];
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
period: string;
|
||||
total: number;
|
||||
accept_all: number;
|
||||
reject_all: number;
|
||||
custom: number;
|
||||
consent_rate: number;
|
||||
}
|
||||
|
||||
export interface ConsentTrendsResponse {
|
||||
site_id: string;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
data: TrendPoint[];
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
}
|
||||
|
||||
export interface RegionMetric {
|
||||
country_code: string;
|
||||
region_code: string | null;
|
||||
total: number;
|
||||
accept_all: number;
|
||||
reject_all: number;
|
||||
custom: number;
|
||||
consent_rate: number;
|
||||
}
|
||||
|
||||
export interface RegionalBreakdownResponse {
|
||||
site_id: string;
|
||||
regions: RegionMetric[];
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSummaryResponse {
|
||||
site_id: string;
|
||||
total_records: number;
|
||||
consent_rate: number;
|
||||
accept_all_rate: number;
|
||||
reject_all_rate: number;
|
||||
custom_rate: number;
|
||||
top_countries: RegionMetric[];
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
}
|
||||
|
||||
// ── A/B Testing ─────────────────────────────────────────────────────
|
||||
|
||||
export type ABTestStatus = 'draft' | 'running' | 'paused' | 'completed';
|
||||
|
||||
export interface ABTestVariant {
|
||||
id: string;
|
||||
ab_test_id: string;
|
||||
name: string;
|
||||
traffic_percentage: number;
|
||||
banner_config_override: Partial<BannerConfig> | null;
|
||||
is_control: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ABTest {
|
||||
id: string;
|
||||
site_id: string;
|
||||
created_by: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: ABTestStatus;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
variants: ABTestVariant[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ABTestVariantCreate {
|
||||
name: string;
|
||||
traffic_percentage: number;
|
||||
banner_config_override?: Partial<BannerConfig> | null;
|
||||
is_control: boolean;
|
||||
}
|
||||
|
||||
export interface ABTestCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
variants: ABTestVariantCreate[];
|
||||
}
|
||||
|
||||
// ── Preference Centre ──────────────────────────────────────────────
|
||||
|
||||
export type PreferenceCategory = 'cookie_consent' | 'communication' | 'data_sharing';
|
||||
|
||||
export interface PreferenceType {
|
||||
id: string;
|
||||
site_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
category: PreferenceCategory;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PreferenceTypeCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
category: PreferenceCategory;
|
||||
is_active?: boolean;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface PreferenceTypeUpdate {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
category?: PreferenceCategory;
|
||||
is_active?: boolean;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface UserPreferenceRecord {
|
||||
id: string;
|
||||
site_id: string;
|
||||
user_identifier_hash: string;
|
||||
preference_type_id: string;
|
||||
value: 'granted' | 'denied';
|
||||
source: 'banner' | 'preference_centre' | 'api';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PreferenceCentreConfig {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
preference_types: PreferenceType[];
|
||||
current_preferences: UserPreferenceRecord[];
|
||||
}
|
||||
|
||||
export interface PreferenceHistoryEntry {
|
||||
preference_type_name: string;
|
||||
preference_type_slug: string;
|
||||
value: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PreferenceHistoryResponse {
|
||||
site_id: string;
|
||||
user_identifier_hash: string;
|
||||
entries: PreferenceHistoryEntry[];
|
||||
}
|
||||
|
||||
// ── Policy Documents ──────────────────────────────────────────────
|
||||
|
||||
export interface PolicyDocument {
|
||||
id: string;
|
||||
site_id: string;
|
||||
type: 'cookie_policy' | 'privacy_section';
|
||||
content_html: string | null;
|
||||
content_markdown: string | null;
|
||||
template_overrides: PolicyTemplateOverrides | null;
|
||||
generated_at: string | null;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PolicyTemplateOverrides {
|
||||
introduction_text?: string | null;
|
||||
additional_sections?: { title: string; content: string }[] | null;
|
||||
language?: string | null;
|
||||
}
|
||||
|
||||
// ── Compliance Scores (server-side monitoring) ─────────────────────
|
||||
|
||||
export interface ComplianceScoreRecord {
|
||||
id: string;
|
||||
site_id: string;
|
||||
framework: string;
|
||||
score: number;
|
||||
status: ComplianceStatus;
|
||||
critical_count: number;
|
||||
warning_count: number;
|
||||
info_count: number;
|
||||
issues: unknown;
|
||||
scanned_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ComplianceScoreSummary {
|
||||
site_id: string;
|
||||
overall_score: number;
|
||||
frameworks: ComplianceScoreRecord[];
|
||||
}
|
||||
|
||||
export interface ComplianceScoreTrendPoint {
|
||||
framework: string;
|
||||
score: number;
|
||||
scanned_at: string;
|
||||
}
|
||||
|
||||
export interface ComplianceScoreTrendResponse {
|
||||
site_id: string;
|
||||
framework: string | null;
|
||||
data_points: ComplianceScoreTrendPoint[];
|
||||
}
|
||||
|
||||
export interface ValidationIssueResponse {
|
||||
check: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationResultResponse {
|
||||
url: string;
|
||||
pre_consent_issues: ValidationIssueResponse[];
|
||||
post_accept_issues: ValidationIssueResponse[];
|
||||
post_reject_issues: ValidationIssueResponse[];
|
||||
dark_pattern_issues: ValidationIssueResponse[];
|
||||
banner_found: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ABTestComplianceResult {
|
||||
variant_id: string;
|
||||
variant_name: string;
|
||||
compliant: boolean;
|
||||
issues: {
|
||||
framework: string;
|
||||
severity: string;
|
||||
rule_id: string;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ── DSAR & Retention ────────────────────────────────────────────────
|
||||
|
||||
export type DsarIdentifierType = 'email' | 'consent_id' | 'visitor_id';
|
||||
export type DsarRequestType = 'access' | 'deletion';
|
||||
export type DsarStatus = 'pending' | 'processing' | 'completed' | 'rejected';
|
||||
|
||||
export interface DsarRequestResponse {
|
||||
id: string;
|
||||
org_id: string;
|
||||
site_id: string | null;
|
||||
requester_identifier: string;
|
||||
requester_identifier_type: DsarIdentifierType;
|
||||
request_type: DsarRequestType;
|
||||
status: DsarStatus;
|
||||
submitted_at: string;
|
||||
processed_at: string | null;
|
||||
processed_by: string | null;
|
||||
notes: string | null;
|
||||
result_data: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RetentionAuditLogResponse {
|
||||
id: string;
|
||||
site_id: string;
|
||||
records_anonymised: number;
|
||||
records_deleted: number;
|
||||
retention_days: number;
|
||||
purge_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Consent Receipts ────────────────────────────────────────────────
|
||||
|
||||
export interface ConsentReceiptResponse {
|
||||
id: string;
|
||||
consent_record_id: string;
|
||||
site_id: string;
|
||||
receipt_data: {
|
||||
receipt_id: string;
|
||||
version: string;
|
||||
timestamp: string;
|
||||
jurisdiction: { country_code: string | null; region_code: string | null };
|
||||
site: { id: string; domain: string | null; name: string | null };
|
||||
page_url: string | null;
|
||||
banner_version_hash: string;
|
||||
banner_content: {
|
||||
banner_config: Record<string, unknown> | null;
|
||||
translation_strings: Record<string, string> | null;
|
||||
};
|
||||
consent: {
|
||||
action: string;
|
||||
categories_accepted: string[];
|
||||
categories_rejected: string[];
|
||||
};
|
||||
signals: {
|
||||
tc_string: string | null;
|
||||
gpp_string: string | null;
|
||||
gcm_state: Record<string, string> | null;
|
||||
gpc_detected: boolean | null;
|
||||
gpc_honoured: boolean | null;
|
||||
};
|
||||
visitor: {
|
||||
visitor_id: string;
|
||||
ip_hash: string | null;
|
||||
user_agent_hash: string | null;
|
||||
};
|
||||
};
|
||||
banner_version_hash: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
256
apps/admin-ui/src/utils/statistics.ts
Normal file
256
apps/admin-ui/src/utils/statistics.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Statistical significance utilities for A/B test analysis.
|
||||
*
|
||||
* Uses the chi-squared test to determine whether observed differences
|
||||
* in consent rates between variants are statistically significant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chi-squared cumulative distribution function approximation.
|
||||
*
|
||||
* Uses the regularised incomplete gamma function for 1 degree of freedom
|
||||
* (2-variant comparison). Returns P(X <= x) for chi-squared distribution.
|
||||
*/
|
||||
function chiSquaredCDF(x: number, df: number): number {
|
||||
if (x <= 0) return 0;
|
||||
// Use the regularised lower incomplete gamma function
|
||||
// For integer/half-integer df, this converges quickly
|
||||
const k = df / 2;
|
||||
const xHalf = x / 2;
|
||||
return regularisedGammaP(k, xHalf);
|
||||
}
|
||||
|
||||
/** Regularised lower incomplete gamma function P(a, x) via series expansion. */
|
||||
function regularisedGammaP(a: number, x: number): number {
|
||||
if (x < 0) return 0;
|
||||
if (x === 0) return 0;
|
||||
|
||||
// Use series expansion for x < a + 1
|
||||
if (x < a + 1) {
|
||||
let sum = 1 / a;
|
||||
let term = 1 / a;
|
||||
for (let n = 1; n < 200; n++) {
|
||||
term *= x / (a + n);
|
||||
sum += term;
|
||||
if (Math.abs(term) < 1e-10 * Math.abs(sum)) break;
|
||||
}
|
||||
return sum * Math.exp(-x + a * Math.log(x) - lnGamma(a));
|
||||
}
|
||||
|
||||
// Use continued fraction for x >= a + 1
|
||||
return 1 - regularisedGammaQ(a, x);
|
||||
}
|
||||
|
||||
/** Regularised upper incomplete gamma function Q(a, x) via continued fraction. */
|
||||
function regularisedGammaQ(a: number, x: number): number {
|
||||
let c = 1e-30;
|
||||
let d = 1 / (x + 1 - a);
|
||||
let h = d;
|
||||
|
||||
for (let n = 1; n < 200; n++) {
|
||||
const an = -n * (n - a);
|
||||
const bn = x + 2 * n + 1 - a;
|
||||
d = bn + an * d;
|
||||
if (Math.abs(d) < 1e-30) d = 1e-30;
|
||||
c = bn + an / c;
|
||||
if (Math.abs(c) < 1e-30) c = 1e-30;
|
||||
d = 1 / d;
|
||||
const delta = d * c;
|
||||
h *= delta;
|
||||
if (Math.abs(delta - 1) < 1e-10) break;
|
||||
}
|
||||
|
||||
return Math.exp(-x + a * Math.log(x) - lnGamma(a)) * h;
|
||||
}
|
||||
|
||||
/** Natural log of the Gamma function using Lanczos approximation. */
|
||||
function lnGamma(z: number): number {
|
||||
const g = 7;
|
||||
const c = [
|
||||
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
|
||||
771.32342877765313, -176.61502916214059, 12.507343278686905,
|
||||
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
|
||||
];
|
||||
|
||||
if (z < 0.5) {
|
||||
return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z);
|
||||
}
|
||||
|
||||
z -= 1;
|
||||
let x = c[0];
|
||||
for (let i = 1; i < g + 2; i++) {
|
||||
x += c[i] / (z + i);
|
||||
}
|
||||
const t = z + g + 0.5;
|
||||
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
|
||||
}
|
||||
|
||||
export type SignificanceLevel = 'not_enough_data' | 'not_significant' | 'trending' | 'significant';
|
||||
|
||||
export interface SignificanceResult {
|
||||
level: SignificanceLevel;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** p-value (0 to 1), lower = more significant */
|
||||
pValue: number | null;
|
||||
/** Confidence percentage (0 to 100) */
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a chi-squared test comparing conversion rates between variants.
|
||||
*
|
||||
* @param observed Array of { successes, total } per variant
|
||||
* @param minSampleSize Minimum total observations before testing (default: 100)
|
||||
*/
|
||||
export function chiSquaredTest(
|
||||
observed: { successes: number; total: number }[],
|
||||
minSampleSize: number = 100,
|
||||
): SignificanceResult {
|
||||
const totalObservations = observed.reduce((sum, v) => sum + v.total, 0);
|
||||
|
||||
if (totalObservations < minSampleSize) {
|
||||
return {
|
||||
level: 'not_enough_data',
|
||||
label: 'Not enough data',
|
||||
pValue: null,
|
||||
confidence: null,
|
||||
};
|
||||
}
|
||||
|
||||
const totalSuccesses = observed.reduce((sum, v) => sum + v.successes, 0);
|
||||
const overallRate = totalSuccesses / totalObservations;
|
||||
|
||||
if (overallRate === 0 || overallRate === 1) {
|
||||
return {
|
||||
level: 'not_significant',
|
||||
label: 'Not significant',
|
||||
pValue: 1,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate chi-squared statistic
|
||||
let chiSq = 0;
|
||||
for (const variant of observed) {
|
||||
const expectedSuccess = variant.total * overallRate;
|
||||
const expectedFailure = variant.total * (1 - overallRate);
|
||||
|
||||
if (expectedSuccess > 0) {
|
||||
chiSq += Math.pow(variant.successes - expectedSuccess, 2) / expectedSuccess;
|
||||
}
|
||||
if (expectedFailure > 0) {
|
||||
const failures = variant.total - variant.successes;
|
||||
chiSq += Math.pow(failures - expectedFailure, 2) / expectedFailure;
|
||||
}
|
||||
}
|
||||
|
||||
const df = observed.length - 1;
|
||||
const pValue = 1 - chiSquaredCDF(chiSq, df);
|
||||
const confidence = (1 - pValue) * 100;
|
||||
|
||||
if (confidence >= 95) {
|
||||
return { level: 'significant', label: 'Significant (>95%)', pValue, confidence };
|
||||
}
|
||||
if (confidence >= 90) {
|
||||
return { level: 'trending', label: 'Trending (>90%)', pValue, confidence };
|
||||
}
|
||||
|
||||
return { level: 'not_significant', label: 'Not significant', pValue, confidence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the recommended sample size per variant.
|
||||
*
|
||||
* Uses the formula for a two-proportion z-test:
|
||||
* n = (Z_alpha/2 + Z_beta)^2 * (p1(1-p1) + p2(1-p2)) / (p1 - p2)^2
|
||||
*
|
||||
* @param baselineRate Current accept-all rate (0-1)
|
||||
* @param minimumDetectableEffect Minimum relative change to detect (e.g. 0.05 for 5%)
|
||||
* @param power Statistical power (default: 0.8)
|
||||
* @param alpha Significance level (default: 0.05)
|
||||
*/
|
||||
export function requiredSampleSize(
|
||||
baselineRate: number,
|
||||
minimumDetectableEffect: number,
|
||||
power: number = 0.8,
|
||||
alpha: number = 0.05,
|
||||
): number {
|
||||
if (baselineRate <= 0 || baselineRate >= 1) return 0;
|
||||
if (minimumDetectableEffect <= 0) return Infinity;
|
||||
|
||||
const p1 = baselineRate;
|
||||
const p2 = baselineRate * (1 + minimumDetectableEffect);
|
||||
|
||||
if (p2 >= 1) return Infinity;
|
||||
|
||||
const zAlpha = normalQuantile(1 - alpha / 2);
|
||||
const zBeta = normalQuantile(power);
|
||||
|
||||
const numerator = Math.pow(zAlpha + zBeta, 2) * (p1 * (1 - p1) + p2 * (1 - p2));
|
||||
const denominator = Math.pow(p1 - p2, 2);
|
||||
|
||||
return Math.ceil(numerator / denominator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate inverse normal CDF (quantile function).
|
||||
*
|
||||
* Uses the rational approximation from Peter Acklam:
|
||||
* https://web.archive.org/web/20151030215612/http://home.online.no/~pjacklam/notes/invnorm/
|
||||
*/
|
||||
function normalQuantile(p: number): number {
|
||||
if (p <= 0) return -Infinity;
|
||||
if (p >= 1) return Infinity;
|
||||
if (p === 0.5) return 0;
|
||||
|
||||
// Coefficients for the rational approximation
|
||||
const a1 = -3.969683028665376e+01;
|
||||
const a2 = 2.209460984245205e+02;
|
||||
const a3 = -2.759285104469687e+02;
|
||||
const a4 = 1.383577518672690e+02;
|
||||
const a5 = -3.066479806614716e+01;
|
||||
const a6 = 2.506628277459239e+00;
|
||||
|
||||
const b1 = -5.447609879822406e+01;
|
||||
const b2 = 1.615858368580409e+02;
|
||||
const b3 = -1.556989798598866e+02;
|
||||
const b4 = 6.680131188771972e+01;
|
||||
const b5 = -1.328068155288572e+01;
|
||||
|
||||
const c1 = -7.784894002430293e-03;
|
||||
const c2 = -3.223964580411365e-01;
|
||||
const c3 = -2.400758277161838e+00;
|
||||
const c4 = -2.549732539343734e+00;
|
||||
const c5 = 4.374664141464968e+00;
|
||||
const c6 = 2.938163982698783e+00;
|
||||
|
||||
const d1 = 7.784695709041462e-03;
|
||||
const d2 = 3.224671290700398e-01;
|
||||
const d3 = 2.445134137142996e+00;
|
||||
const d4 = 3.754408661907416e+00;
|
||||
|
||||
const pLow = 0.02425;
|
||||
const pHigh = 1 - pLow;
|
||||
|
||||
let q: number;
|
||||
let r: number;
|
||||
|
||||
if (p < pLow) {
|
||||
// Rational approximation for lower region
|
||||
q = Math.sqrt(-2 * Math.log(p));
|
||||
return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
|
||||
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
||||
} else if (p <= pHigh) {
|
||||
// Rational approximation for central region
|
||||
q = p - 0.5;
|
||||
r = q * q;
|
||||
return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q /
|
||||
(((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
|
||||
} else {
|
||||
// Rational approximation for upper region
|
||||
q = Math.sqrt(-2 * Math.log(1 - p));
|
||||
return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
|
||||
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
||||
}
|
||||
}
|
||||
7
apps/admin-ui/src/vite-env.d.ts
vendored
Normal file
7
apps/admin-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** Virtual module provided by the ee-extensions Vite plugin. */
|
||||
declare module 'virtual:ee-extensions' {
|
||||
const mod: unknown;
|
||||
export default mod;
|
||||
}
|
||||
34
apps/admin-ui/tsconfig.app.json
Normal file
34
apps/admin-ui/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/admin-ui/tsconfig.json
Normal file
7
apps/admin-ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/admin-ui/tsconfig.node.json
Normal file
26
apps/admin-ui/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
44
apps/admin-ui/vite.config.ts
Normal file
44
apps/admin-ui/vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
/**
|
||||
* Vite plugin that provides a no-op ``virtual:ee-extensions`` module.
|
||||
*
|
||||
* In the cloud repo this plugin is replaced with one that points to
|
||||
* the real EE register module. In the OSS repo the virtual module
|
||||
* simply exports nothing, making ``discoverExtensions()`` a no-op.
|
||||
*/
|
||||
function eeExtensions() {
|
||||
const virtualModuleId = 'virtual:ee-extensions'
|
||||
const resolvedId = '\0' + virtualModuleId
|
||||
|
||||
return {
|
||||
name: 'ee-extensions',
|
||||
resolveId(id: string) {
|
||||
if (id === virtualModuleId) return resolvedId
|
||||
},
|
||||
load(id: string) {
|
||||
if (id === resolvedId) return 'export default undefined;'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), eeExtensions()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
28
apps/admin-ui/vitest.config.ts
Normal file
28
apps/admin-ui/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
/** No-op virtual module for EE extensions (see vite.config.ts for details). */
|
||||
function eeExtensions() {
|
||||
const virtualModuleId = 'virtual:ee-extensions'
|
||||
const resolvedId = '\0' + virtualModuleId
|
||||
return {
|
||||
name: 'ee-extensions',
|
||||
resolveId(id: string) { if (id === virtualModuleId) return resolvedId },
|
||||
load(id: string) { if (id === resolvedId) return 'export default undefined;' },
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [eeExtensions()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user