Compare commits
29 Commits
84e41857c3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc78c0550e | ||
|
|
0371a36209 | ||
|
|
6cd6ce01eb | ||
|
|
ac719b219f | ||
|
|
283f75a5e2 | ||
|
|
6a513a97ce | ||
|
|
6211290923 | ||
|
|
6b40c04b0d | ||
|
|
355b5156a5 | ||
|
|
51b8e15726 | ||
|
|
35ea49d6d2 | ||
|
|
d3af80145b | ||
|
|
1c2bdbf310 | ||
|
|
f8cdbf8d74 | ||
|
|
f195a44707 | ||
|
|
cb59bea178 | ||
|
|
7680b0eb91 | ||
|
|
08e0ae7e83 | ||
|
|
3265228ce6 | ||
|
|
062a384444 | ||
|
|
d8e0a34e04 | ||
|
|
142e2373d3 | ||
|
|
bebcf901f4 | ||
|
|
e0f1dd43e8 | ||
|
|
80dfc15319 | ||
|
|
10e5c92882 | ||
|
|
bd465008e5 | ||
|
|
0fbe2717f2 | ||
|
|
8d15ec4398 |
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@@ -11,8 +11,35 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ── Detect which apps changed ──────────────────────────────────────
|
||||||
|
changes:
|
||||||
|
name: Detect changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
api: ${{ steps.filter.outputs.api }}
|
||||||
|
scanner: ${{ steps.filter.outputs.scanner }}
|
||||||
|
banner: ${{ steps.filter.outputs.banner }}
|
||||||
|
admin-ui: ${{ steps.filter.outputs.admin-ui }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
api:
|
||||||
|
- 'apps/api/**'
|
||||||
|
scanner:
|
||||||
|
- 'apps/scanner/**'
|
||||||
|
banner:
|
||||||
|
- 'apps/banner/**'
|
||||||
|
admin-ui:
|
||||||
|
- 'apps/admin-ui/**'
|
||||||
|
|
||||||
|
# ── API ────────────────────────────────────────────────────────────
|
||||||
api-lint:
|
api-lint:
|
||||||
name: API Lint
|
name: API Lint
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.api == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -29,6 +56,8 @@ jobs:
|
|||||||
|
|
||||||
api-test:
|
api-test:
|
||||||
name: API Tests
|
name: API Tests
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.api == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -74,8 +103,11 @@ jobs:
|
|||||||
DATABASE_URL: postgresql://consentos_test:consentos_test@localhost:5432/consentos_test
|
DATABASE_URL: postgresql://consentos_test:consentos_test@localhost:5432/consentos_test
|
||||||
- run: pytest --cov=src --cov-report=term-missing -v
|
- run: pytest --cov=src --cov-report=term-missing -v
|
||||||
|
|
||||||
|
# ── Scanner ────────────────────────────────────────────────────────
|
||||||
scanner-lint:
|
scanner-lint:
|
||||||
name: Scanner Lint
|
name: Scanner Lint
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.scanner == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -92,6 +124,8 @@ jobs:
|
|||||||
|
|
||||||
scanner-test:
|
scanner-test:
|
||||||
name: Scanner Tests
|
name: Scanner Tests
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.scanner == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -105,8 +139,11 @@ jobs:
|
|||||||
- run: pip install -e ".[dev]"
|
- run: pip install -e ".[dev]"
|
||||||
- run: pytest --cov=src --cov-report=term-missing -v
|
- run: pytest --cov=src --cov-report=term-missing -v
|
||||||
|
|
||||||
|
# ── Banner ─────────────────────────────────────────────────────────
|
||||||
banner-lint:
|
banner-lint:
|
||||||
name: Banner Lint & Typecheck
|
name: Banner Lint & Typecheck
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.banner == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -123,6 +160,8 @@ jobs:
|
|||||||
|
|
||||||
banner-test:
|
banner-test:
|
||||||
name: Banner Tests
|
name: Banner Tests
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.banner == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -139,8 +178,9 @@ jobs:
|
|||||||
|
|
||||||
banner-build:
|
banner-build:
|
||||||
name: Banner Build
|
name: Banner Build
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [banner-test, banner-lint]
|
needs: [banner-test, banner-lint]
|
||||||
|
if: needs.changes.outputs.banner == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/banner
|
working-directory: apps/banner
|
||||||
@@ -163,8 +203,11 @@ jobs:
|
|||||||
echo "::warning::consent-loader.js is ${LOADER_SIZE} bytes (>20KB) — consider optimising"
|
echo "::warning::consent-loader.js is ${LOADER_SIZE} bytes (>20KB) — consider optimising"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Admin UI ───────────────────────────────────────────────────────
|
||||||
admin-ui-lint:
|
admin-ui-lint:
|
||||||
name: Admin UI Typecheck
|
name: Admin UI Typecheck
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.admin-ui == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -181,6 +224,8 @@ jobs:
|
|||||||
|
|
||||||
admin-ui-test:
|
admin-ui-test:
|
||||||
name: Admin UI Tests
|
name: Admin UI Tests
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.admin-ui == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -197,8 +242,9 @@ jobs:
|
|||||||
|
|
||||||
admin-ui-build:
|
admin-ui-build:
|
||||||
name: Admin UI Build
|
name: Admin UI Build
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [admin-ui-test, admin-ui-lint]
|
needs: [admin-ui-test, admin-ui-lint]
|
||||||
|
if: needs.changes.outputs.admin-ui == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/admin-ui
|
working-directory: apps/admin-ui
|
||||||
|
|||||||
30
.github/workflows/pr-title.yml
vendored
Normal file
30
.github/workflows/pr-title.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: PR Title
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Conventional commit title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check PR title
|
||||||
|
uses: amannn/action-semantic-pull-request@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
types: |
|
||||||
|
feat
|
||||||
|
fix
|
||||||
|
chore
|
||||||
|
refactor
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
style
|
||||||
|
perf
|
||||||
|
ci
|
||||||
|
build
|
||||||
|
requireScope: false
|
||||||
|
subjectPattern: ^.+$
|
||||||
|
subjectPatternError: "PR title must follow conventional commits: type: description (e.g. feat: add cookie categories)"
|
||||||
181
.github/workflows/release.yml
vendored
Normal file
181
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Cut a release and build container images.
|
||||||
|
#
|
||||||
|
# Triggered manually via ``workflow_dispatch`` (Actions tab → Run
|
||||||
|
# workflow) when you're ready to ship. Not on every PR merge.
|
||||||
|
#
|
||||||
|
# Flow:
|
||||||
|
# 1. ``ietf-tools/semver-action`` derives the next version from
|
||||||
|
# conventional commit messages since the last tag (feat → minor,
|
||||||
|
# fix → patch, breaking → major).
|
||||||
|
# 2. ``requarks/changelog-action`` generates release notes from the
|
||||||
|
# commit diff between the new and previous tags.
|
||||||
|
# 3. ``ncipollo/release-action`` creates the GitHub Release.
|
||||||
|
# 4. ``requarks/changelog-action`` writes CHANGELOG.md and
|
||||||
|
# ``stefanzweifel/git-auto-commit-action`` commits it back to
|
||||||
|
# master with ``[skip ci]``.
|
||||||
|
# 5. All three container images are built and pushed to GHCR,
|
||||||
|
# tagged with the semver version + ``latest``.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Version + Release + Changelog ────────────────────────────────
|
||||||
|
version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
new: ${{ steps.semver.outputs.next }}
|
||||||
|
newStrict: ${{ steps.semver.outputs.nextStrict }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get next version
|
||||||
|
id: semver
|
||||||
|
uses: ietf-tools/semver-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
branch: master
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
id: changelog
|
||||||
|
uses: requarks/changelog-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
fromTag: master
|
||||||
|
toTag: ${{ steps.semver.outputs.current }}
|
||||||
|
writeToFile: false
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: ncipollo/release-action@v1.12.0
|
||||||
|
with:
|
||||||
|
allowUpdates: true
|
||||||
|
draft: false
|
||||||
|
makeLatest: true
|
||||||
|
tag: ${{ steps.semver.outputs.next }}
|
||||||
|
body: ${{ steps.changelog.outputs.changes }}
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Write CHANGELOG.md
|
||||||
|
uses: requarks/changelog-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
fromTag: ${{ steps.semver.outputs.next }}
|
||||||
|
toTag: ${{ steps.semver.outputs.current }}
|
||||||
|
writeToFile: true
|
||||||
|
|
||||||
|
- name: Commit CHANGELOG.md
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v4
|
||||||
|
with:
|
||||||
|
branch: master
|
||||||
|
commit_message: "docs: update CHANGELOG.md for ${{ steps.semver.outputs.next }} [skip ci]"
|
||||||
|
file_pattern: CHANGELOG.md
|
||||||
|
|
||||||
|
# ── Build and push container images ──────────────────────────────
|
||||||
|
build-api:
|
||||||
|
name: API image
|
||||||
|
needs: version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/consentos/consentos-api
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: apps/api
|
||||||
|
file: apps/api/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-scanner:
|
||||||
|
name: Scanner image
|
||||||
|
needs: version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/consentos/consentos-scanner
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: apps/scanner
|
||||||
|
file: apps/scanner/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-admin-ui:
|
||||||
|
name: Admin UI image
|
||||||
|
needs: version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/consentos/consentos-admin-ui
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.newStrict }}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
# Context is the repo root — the admin-ui Dockerfile pulls in
|
||||||
|
# apps/banner/ alongside apps/admin-ui/ and bundles the banner
|
||||||
|
# output at the nginx root.
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/admin-ui/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -5,8 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-18
|
## [0.1.0] - 2026-03-18
|
||||||
|
|
||||||
Initial public release of ConsentOS.
|
Initial public release of ConsentOS.
|
||||||
|
|||||||
71
Dockerfile
Normal file
71
Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# ── Build stage: Python deps ────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc libpq-dev curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY apps/api/pyproject.toml ./api/pyproject.toml
|
||||||
|
COPY apps/scanner/pyproject.toml ./scanner/pyproject.toml
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install api/.
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install scanner/. \
|
||||||
|
&& PYTHONPATH=/install/lib/python3.12/site-packages \
|
||||||
|
/install/bin/playwright install chromium --with-deps
|
||||||
|
|
||||||
|
# ── Build stage: banner bundle ─────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS banner-builder
|
||||||
|
WORKDIR /build/banner
|
||||||
|
COPY apps/banner/package.json apps/banner/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/banner/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Build stage: admin UI ──────────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS admin-builder
|
||||||
|
WORKDIR /build/admin
|
||||||
|
COPY apps/admin-ui/package.json apps/admin-ui/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/admin-ui/ .
|
||||||
|
COPY --from=banner-builder /build/banner/dist/ ./public/
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
# ── Runtime stage ──────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 postgresql-client curl tini supervisor nginx \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
# Copy Python deps from builder
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/api/src ./src
|
||||||
|
COPY apps/api/alembic ./alembic
|
||||||
|
COPY apps/api/alembic.ini ./alembic.ini
|
||||||
|
COPY apps/scanner/src ./src_scanner
|
||||||
|
|
||||||
|
RUN if [ -d src_scanner ]; then \
|
||||||
|
cp -r src_scanner/* src/ 2>/dev/null || true; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy built Admin UI static files
|
||||||
|
COPY --from=admin-builder /build/admin/dist /var/www/html
|
||||||
|
|
||||||
|
# Copy configs
|
||||||
|
COPY apps/admin-ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY supervisord.conf /etc/supervisord.conf
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["/usr/bin/tini", "--", "supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
88
EASYPANEL-README.md
Normal file
88
EASYPANEL-README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# ConsentOS — Repository Structure for Easypanel
|
||||||
|
|
||||||
|
## Files to Add to Repository
|
||||||
|
|
||||||
|
```
|
||||||
|
consent-os/ ← ต้อง push ขึ้น Git
|
||||||
|
├── Dockerfile.app ← ✅ สร้างใหม่ (API container)
|
||||||
|
├── supervisord.conf ← ✅ สร้างใหม่ (process manager)
|
||||||
|
├── docker-compose.yml ← ✅ สร้างให้แล้ว
|
||||||
|
├── .env.example ← ✅ สร้างให้แล้ว
|
||||||
|
├── DEPLOY.md ← ✅ คู่มือ deploy ทั่วไป
|
||||||
|
└── EASYPANEL.md ← ✅ คู่มือ deploy บน Easypanel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files ที่มีอยู่แล้วใน Repo (อย่าลบ)
|
||||||
|
|
||||||
|
```
|
||||||
|
consent-os/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ ← ✅ keep
|
||||||
|
│ │ ├── Dockerfile ← ใช้แทน Dockerfile.app สำหรับ dev
|
||||||
|
│ │ ├── src/ ← ✅ keep
|
||||||
|
│ │ ├── pyproject.toml ← ✅ keep
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── scanner/ ← ✅ keep
|
||||||
|
│ │ ├── Dockerfile ← ใช้แทน Dockerfile.app สำหรับ dev
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── admin-ui/ ← ✅ keep (deploy แยก)
|
||||||
|
│ │ └── Dockerfile
|
||||||
|
│ └── banner/ ← ✅ keep (admin-ui ดึงมาตอน build)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy Flow on Easypanel
|
||||||
|
|
||||||
|
```
|
||||||
|
Git push
|
||||||
|
│
|
||||||
|
├──► Project: consentos
|
||||||
|
│ Service: consentos-db (Postgres) ───┐
|
||||||
|
│ Service: consentos-redis (Redis) ───┤
|
||||||
|
│ Service: consentos-app (App) ───┤
|
||||||
|
│ ───┘
|
||||||
|
│ (auto network)
|
||||||
|
│
|
||||||
|
└──► Project: consentos-admin
|
||||||
|
Service: consentos-admin (Admin UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### 1. Database Connection
|
||||||
|
- Easypanel ใช้ **service name** เป็น hostname ภายใน Docker network
|
||||||
|
- `DATABASE_URL=postgresql+asyncpg://consentos:PASS@consentos-db:5432/consentos`
|
||||||
|
- `REDIS_URL=redis://:PASS@consentos-redis:6379/0`
|
||||||
|
|
||||||
|
### 2. Dockerfile.app Location
|
||||||
|
- ต้องอยู่ที่ **root ของ repo** ไม่ใช่ใน apps/
|
||||||
|
- เพราะ Easypanel build จาก repo root
|
||||||
|
|
||||||
|
### 3. Admin UI Deploy แยก
|
||||||
|
- Admin UI deploy เป็น **separate project/service** เพราะ:
|
||||||
|
- ใช้ Dockerfile คนละตัว
|
||||||
|
- ต้องการ domain + SSL แยก
|
||||||
|
- nginx รันใน container ของตัวเอง
|
||||||
|
|
||||||
|
### 4. CORS
|
||||||
|
- หลัง deploy admin UI → copy domain ไปใส่ใน `ALLOWED_ORIGINS` ของ consentos-app
|
||||||
|
|
||||||
|
## Quick Setup Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repo
|
||||||
|
git clone https://github.com/kunthawat/consentos.git
|
||||||
|
cd consentos
|
||||||
|
|
||||||
|
# Copy deployment files
|
||||||
|
cp ~/consent-deploy/Dockerfile.app .
|
||||||
|
cp ~/consent-deploy/supervisord.conf .
|
||||||
|
cp ~/consent-deploy/docker-compose.yml .
|
||||||
|
cp ~/consent-deploy/.env.example .env
|
||||||
|
cp ~/consent-deploy/EASYPANEL.md .
|
||||||
|
|
||||||
|
# Push to your Git
|
||||||
|
git add .
|
||||||
|
git commit -m "Add Easypanel deployment config"
|
||||||
|
git push
|
||||||
|
```
|
||||||
273
EASYPANEL.md
Normal file
273
EASYPANEL.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Deploy ConsentOS on Easypanel
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
consentos (Project)
|
||||||
|
├── consentos-db ← PostgreSQL (Easypanel managed)
|
||||||
|
├── consentos-redis ← Redis (Easypanel managed)
|
||||||
|
└── consentos-app ← 1 container รันทุกอย่าง (API + Worker + Beat + Scanner)
|
||||||
|
└── 5 services ภายใน via supervisord
|
||||||
|
|
||||||
|
consentos-admin (Separate Project)
|
||||||
|
└── consentos-admin ← Admin UI (nginx, static files)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Clone Repo to Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/kunthawat/consentos.git
|
||||||
|
# Push to your own Git repo (GitHub/Gitea)
|
||||||
|
# ต้องมี Dockerfile.app + supervisord.conf + apps/ ที่ root
|
||||||
|
```
|
||||||
|
|
||||||
|
> ถ้าต้องการแยก repo — ต้อง push เฉพาะ apps/ กับ Dockerfile.app + supervisord.conf + docker-compose.yml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Create Database Services
|
||||||
|
|
||||||
|
### 2.1 PostgreSQL
|
||||||
|
|
||||||
|
ใน Easypanel → สร้าง **Postgres Service**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Name | `consentos-db` |
|
||||||
|
| Database | `consentos` |
|
||||||
|
| User | `consentos` |
|
||||||
|
| Password | (generate strong password) |
|
||||||
|
|
||||||
|
**Copy connection details** — จะได้ใช้ใน env:
|
||||||
|
- Host: `consentos-db` (internal Docker network)
|
||||||
|
- Port: `5432`
|
||||||
|
- User: `consentos`
|
||||||
|
- Database: `consentos`
|
||||||
|
|
||||||
|
### 2.2 Redis
|
||||||
|
|
||||||
|
ใน Easypanel → สร้าง **Redis Service**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Name | `consentos-redis` |
|
||||||
|
| Password | (generate strong password) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Create App Service (Backend)
|
||||||
|
|
||||||
|
ใน Easypanel → สร้าง **App Service**:
|
||||||
|
|
||||||
|
### Source
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Build Method | **Dockerfile** |
|
||||||
|
| Dockerfile Path | `Dockerfile.app` |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ── Application ───────────────────────────────────────────────────────
|
||||||
|
APP_NAME=ConsentOS
|
||||||
|
ENVIRONMENT=production
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ── Database (use Easypanel service names as host) ───────────────────
|
||||||
|
DATABASE_URL=postgresql+asyncpg://consentos:PASSWORD@consentos-db:5432/consentos
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────────────────
|
||||||
|
REDIS_URL=redis://:PASSWORD@consentos-redis:6379/0
|
||||||
|
|
||||||
|
# ── Authentication ────────────────────────────────────────────────────
|
||||||
|
# Generate with: openssl rand -base64 48
|
||||||
|
JWT_SECRET_KEY=YOUR_JWT_SECRET_HERE
|
||||||
|
PSEUDONYMISATION_SECRET=YOUR_JWT_SECRET_HERE
|
||||||
|
|
||||||
|
# ── Admin Bootstrap (runs once on first deploy) ──────────────────────
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=YOUR_ADMIN_PASSWORD
|
||||||
|
INITIAL_ADMIN_FULL_NAME=Admin
|
||||||
|
INITIAL_ORG_NAME=Your Company
|
||||||
|
INITIAL_ORG_SLUG=your-company
|
||||||
|
|
||||||
|
# ── CORS ───────────────────────────────────────────────────────────────
|
||||||
|
# ตั้ง domain ของ admin UI ที่จะ deploy ใน step ถัดไป
|
||||||
|
ALLOWED_ORIGINS=https://admin.yourdomain.com,https://consent.yourdomain.com
|
||||||
|
|
||||||
|
# ── Scanner (optional) ────────────────────────────────────────────────
|
||||||
|
ENABLE_SCANNER=false
|
||||||
|
CRAWLER_HEADLESS=true
|
||||||
|
CRAWLER_TIMEOUT_MS=30000
|
||||||
|
MAX_PAGES_PER_SCAN=50
|
||||||
|
|
||||||
|
# ── Performance ──────────────────────────────────────────────────────
|
||||||
|
API_WORKERS=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mounts (Data Persistence)
|
||||||
|
|
||||||
|
| Type | mountPath |
|
||||||
|
|------|-----------|
|
||||||
|
| **Volume** | `/var/log/supervisor` |
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
| Published | Target |
|
||||||
|
|-----------|--------|
|
||||||
|
| `8000` | `8000` |
|
||||||
|
|
||||||
|
### Deploy Settings
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Container Replicas | `1` |
|
||||||
|
| Shm Size | `256mb` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Create Admin UI (Separate App)
|
||||||
|
|
||||||
|
สร้าง **อีก Project** ชื่อ `consentos-admin`:
|
||||||
|
|
||||||
|
### Source
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Build Method | **Dockerfile** |
|
||||||
|
| Dockerfile Path | `apps/admin-ui/Dockerfile` |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# URL ของ API service (ใช้ service name ของ Easypanel)
|
||||||
|
VITE_API_URL=https://consent.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|
เพิ่ม domain `admin.yourdomain.com` → ใช้ SSL auto ของ Easypanel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Update CORS + Deploy
|
||||||
|
|
||||||
|
หลัง deploy admin UI ได้ domain แล้ว:
|
||||||
|
|
||||||
|
1. กลับไปที่ `consentos-app` → **Environment** → แก้ `ALLOWED_ORIGINS`:
|
||||||
|
```
|
||||||
|
ALLOWED_ORIGINS=https://admin.yourdomain.com,https://consent.yourdomain.com
|
||||||
|
```
|
||||||
|
2. **Redeploy** `consentos-app`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: First-Time Setup
|
||||||
|
|
||||||
|
หลัง container start ครั้งแรก → bootstrap script รันอัตโนมัติ:
|
||||||
|
- Database migrations (Alembic)
|
||||||
|
- Initial admin user creation
|
||||||
|
- Seed known cookies
|
||||||
|
|
||||||
|
**ตรวจสอบ logs:**
|
||||||
|
```
|
||||||
|
Easypanel → consentos-app → Logs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Required (ต้องกำหนดเอง)
|
||||||
|
|
||||||
|
| Variable | Example | ที่ไหนได้มา |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `JWT_SECRET_KEY` | `openssl rand -base64 48` | Generate |
|
||||||
|
| `DATABASE_URL` | `postgresql+asyncpg://consentos:PASS@consentos-db:5432/consentos` | From Easypanel PostgreSQL |
|
||||||
|
| `REDIS_URL` | `redis://:PASS@consentos-redis:6379/0` | From Easypanel Redis |
|
||||||
|
| `INITIAL_ADMIN_EMAIL` | `admin@example.com` | กำหนดเอง |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` | `Str0ng!Pass` | กำหนดเอง |
|
||||||
|
| `ALLOWED_ORIGINS` | `https://admin.example.com` | หลัง deploy admin UI |
|
||||||
|
|
||||||
|
### Optional (มี default แล้ว)
|
||||||
|
|
||||||
|
| Variable | Default | คำอธิบาย |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| `API_WORKERS` | `2` | จำนวน uvicorn workers |
|
||||||
|
| `ENABLE_SCANNER` | `false` | เปิด scanner (ใช้ RAM เยอะ) |
|
||||||
|
| `LOG_LEVEL` | `INFO` | DEBUG สำหรับ verbose logs |
|
||||||
|
| `DEBUG` | `false` | เปิด FastAPI debug mode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
| Data | Storage | หายไหมตอน redeploy? |
|
||||||
|
|------|---------|---------------------|
|
||||||
|
| Database | Easypanel `consentos-db` volume | ✅ ไม่หาย |
|
||||||
|
| Redis | Easypanel `consentos-redis` volume | ✅ ไม่หาย |
|
||||||
|
| Code | Container image | ❌ Rebuild ตามปกติ |
|
||||||
|
| Logs | `/var/log/supervisor` mount | ✅ Mounted volume |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update / Redeploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Pull code ใน Git repo
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 2. Redeploy ใน Easypanel
|
||||||
|
# consentos-app → Deploy (Redeploy button)
|
||||||
|
# consentos-admin → Deploy (Redeploy button)
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือตั้ง **Auto Deploy** → Easypanel จะ deploy อัตโนมัติเมื่อ push ไปที่ Git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bootstrap failed
|
||||||
|
```bash
|
||||||
|
# ดู logs
|
||||||
|
consentos-app → Logs
|
||||||
|
|
||||||
|
# ถ้า admin สร้างไปแล้ว → bootstrap script จะ skip
|
||||||
|
# ถ้าต้องการ reset admin:
|
||||||
|
# ไปที่ console แล้ว:
|
||||||
|
docker exec -it consentos-app python -m src.cli.bootstrap_admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
เพิ่ม domain ใหม่เข้า `ALLOWED_ORIGINS` แล้ว redeploy
|
||||||
|
|
||||||
|
### Scanner กิน RAM เยอะ
|
||||||
|
```env
|
||||||
|
ENABLE_SCANNER=false # ปิดไปก่อน
|
||||||
|
```
|
||||||
|
|
||||||
|
### Celery worker ไม่ทำงาน
|
||||||
|
```bash
|
||||||
|
# ดู worker logs
|
||||||
|
consentos-app → Console
|
||||||
|
supervisorctl status
|
||||||
|
supervisorctl tail worker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Requirements
|
||||||
|
|
||||||
|
| Service | RAM (approximate) |
|
||||||
|
|---------|------------------|
|
||||||
|
| consentos-db | ~256-512 MB |
|
||||||
|
| consentos-redis | ~64-128 MB |
|
||||||
|
| consentos-app (API) | ~256-512 MB |
|
||||||
|
| consentos-app (Worker) | ~256-512 MB |
|
||||||
|
| consentos-app (Scanner) | ~512-1024 MB (ถ้าเปิด) |
|
||||||
|
| **Total (without scanner)** | ~576-1152 MB |
|
||||||
|
| **Total (with scanner)** | ~1088-2176 MB |
|
||||||
|
|
||||||
|
**แนะนำ:** VPS/Server อย่างน้อย **2 GB RAM** (ถ้าไม่ใช้ scanner) หรือ **4 GB** (ถ้าใช้ scanner)
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
server {
|
worker_processes auto;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
|
||||||
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
root /usr/share/nginx/html;
|
root /var/www/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Banner entry points — cross-origin script loads from customer
|
# Health check endpoint
|
||||||
# sites, so they need permissive CORS. Served from the web root
|
location = /health {
|
||||||
# because the loader derives the bundle URL from its own origin
|
access_log off;
|
||||||
# (see apps/banner/src/loader.ts). Declared before the SPA
|
return 200 "nginx ok\n";
|
||||||
# fallback so nginx doesn't rewrite them to index.html when the
|
add_header Content-Type text/plain;
|
||||||
# files aren't yet built in dev.
|
}
|
||||||
|
|
||||||
|
# Banner entry points
|
||||||
location = /consent-loader.js {
|
location = /consent-loader.js {
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
||||||
@@ -23,27 +38,33 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback — serve index.html for all other routes
|
# Proxy API requests to FastAPI backend — strip /api prefix
|
||||||
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/ {
|
location /api/ {
|
||||||
resolver 127.0.0.11 valid=10s;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
set $upstream http://api:8000;
|
|
||||||
proxy_pass $upstream;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
location /docs {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /openapi.json {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import { trackPageView } from './services/analytics';
|
import { trackPageView } from './services/analytics';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import ComplianceDashboardPage from './pages/ComplianceDashboardPage';
|
import AccountPage from './pages/AccountPage';
|
||||||
|
import ConsentRecordsPage from './pages/ConsentRecordsPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import SiteDetailPage from './pages/SiteDetailPage';
|
import SiteDetailPage from './pages/SiteDetailPage';
|
||||||
@@ -58,7 +59,8 @@ function AppRoutes() {
|
|||||||
<Route path="/sites" element={<SitesPage />} />
|
<Route path="/sites" element={<SitesPage />} />
|
||||||
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
||||||
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
||||||
<Route path="/compliance" element={<ComplianceDashboardPage />} />
|
<Route path="/consent" element={<ConsentRecordsPage />} />
|
||||||
|
<Route path="/account" element={<AccountPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
{extensionPages
|
{extensionPages
|
||||||
.filter((p) => p.protected !== false)
|
.filter((p) => p.protected !== false)
|
||||||
|
|||||||
@@ -17,3 +17,28 @@ export async function getMe(): Promise<User> {
|
|||||||
const { data } = await apiClient.get<User>('/auth/me');
|
const { data } = await apiClient.get<User>('/auth/me');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: string;
|
||||||
|
organisation_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(): Promise<Profile> {
|
||||||
|
const { data } = await apiClient.get<Profile>('/auth/me');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(body: { email?: string; full_name?: string }): Promise<Profile> {
|
||||||
|
const { data } = await apiClient.patch<Profile>('/auth/me', body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(body: {
|
||||||
|
current_password: string;
|
||||||
|
new_password: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await apiClient.patch('/auth/me/password', body);
|
||||||
|
}
|
||||||
|
|||||||
12
apps/admin-ui/src/api/consent.ts
Normal file
12
apps/admin-ui/src/api/consent.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ConsentRecord, PaginatedResponse } from '../types/api';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export async function listConsentRecords(
|
||||||
|
siteId: string,
|
||||||
|
params?: { visitor_id?: string; page?: number; page_size?: number },
|
||||||
|
): Promise<PaginatedResponse<ConsentRecord>> {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<ConsentRecord>>('/consent/', {
|
||||||
|
params: { site_id: siteId, ...params },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { getNavItems } from '../extensions/registry';
|
import { getNavItems } from '../extensions/registry';
|
||||||
|
|
||||||
const CORE_NAV_ITEMS = [
|
const CORE_NAV_ITEMS = [
|
||||||
{ path: '/sites', label: 'Sites', order: 10 },
|
{ path: '/sites', label: 'Sites', order: 10 },
|
||||||
{ path: '/compliance', label: 'Compliance', order: 20 },
|
{ path: '/consent', label: 'Consent Records', order: 15 },
|
||||||
{ path: '/settings', label: 'Settings', order: 90 },
|
{ path: '/settings', label: 'Settings', order: 90 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close user menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
const NAV_ITEMS = useMemo(() => {
|
const NAV_ITEMS = useMemo(() => {
|
||||||
const extensionItems = getNavItems().map((item) => ({
|
const extensionItems = getNavItems().map((item) => ({
|
||||||
@@ -65,19 +80,48 @@ export default function Layout() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: user info + mobile hamburger */}
|
{/* Right: user menu + mobile hamburger */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="hidden items-center gap-3 md:flex">
|
<div className="relative hidden md:block" ref={userMenuRef}>
|
||||||
<span className="text-sm text-text-secondary">
|
|
||||||
{user?.full_name ?? user?.email}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
type="button"
|
||||||
className="text-sm text-text-tertiary hover:text-foreground"
|
onClick={() => setUserMenuOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-text-secondary transition-colors hover:bg-mist hover:text-foreground"
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-copper/10 font-heading text-xs font-semibold text-copper">
|
||||||
|
{(user?.full_name ?? user?.email ?? '?')[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{user?.full_name ?? user?.email}
|
||||||
|
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setUserMenuOpen(false); navigate('/account'); }}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-foreground hover:bg-mist"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Account
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setUserMenuOpen(false); logout(); }}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-text-tertiary hover:bg-mist hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
186
apps/admin-ui/src/components/SiteCategoriesTab.tsx
Normal file
186
apps/admin-ui/src/components/SiteCategoriesTab.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { updateSiteConfig } from '../api/sites';
|
||||||
|
import { trackConfigChange } from '../services/analytics';
|
||||||
|
import type { CategorySlug, SiteConfig } from '../types/api';
|
||||||
|
import { ALL_COOKIE_CATEGORIES } from '../types/api';
|
||||||
|
import { Alert } from './ui/alert';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
siteId: string;
|
||||||
|
config: SiteConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-site control over which cookie categories the banner displays.
|
||||||
|
*
|
||||||
|
* ``necessary`` is always on and can't be disabled. A category that's
|
||||||
|
* unchecked here is hidden from the banner AND treated as permanently
|
||||||
|
* unconsented, so any cookie in that category stays blocked. That's
|
||||||
|
* the correct semantics for a site that genuinely doesn't use e.g.
|
||||||
|
* marketing cookies: the operator declares it, the visitor never
|
||||||
|
* sees the toggle, and the auto-blocker enforces it.
|
||||||
|
*
|
||||||
|
* ``null`` on the site config means "inherit from the cascade"
|
||||||
|
* (group → org → system default of all five). The save button
|
||||||
|
* always writes an explicit list; the "Reset to inherited" button
|
||||||
|
* clears the override by sending ``null``.
|
||||||
|
*/
|
||||||
|
export default function SiteCategoriesTab({ siteId, config }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const initiallyEnabled = useMemo<Set<CategorySlug>>(() => {
|
||||||
|
const raw = config?.enabled_categories;
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
return new Set(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
|
||||||
|
}
|
||||||
|
const known = new Set<CategorySlug>(ALL_COOKIE_CATEGORIES.map((c) => c.slug));
|
||||||
|
const picked = new Set<CategorySlug>(raw.filter((s): s is CategorySlug => known.has(s)));
|
||||||
|
picked.add('necessary');
|
||||||
|
return picked;
|
||||||
|
}, [config?.enabled_categories]);
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState<Set<CategorySlug>>(initiallyEnabled);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const isInherited = config?.enabled_categories == null;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: Partial<SiteConfig>) => updateSiteConfig(siteId, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'inheritance'] });
|
||||||
|
trackConfigChange('site_categories', { site_id: siteId });
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (slug: CategorySlug, locked: boolean) => {
|
||||||
|
if (locked) return;
|
||||||
|
setEnabled((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(slug)) {
|
||||||
|
next.delete(slug);
|
||||||
|
} else {
|
||||||
|
next.add(slug);
|
||||||
|
}
|
||||||
|
next.add('necessary');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = ALL_COOKIE_CATEGORIES.map((c) => c.slug).filter((slug) => enabled.has(slug));
|
||||||
|
mutation.mutate({ enabled_categories: payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetToInherited = () => {
|
||||||
|
mutation.mutate({ enabled_categories: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const allActive = ALL_COOKIE_CATEGORIES.every((c) => enabled.has(c.slug));
|
||||||
|
const dirty =
|
||||||
|
!isInherited &&
|
||||||
|
(config?.enabled_categories ?? []).slice().sort().join(',') !==
|
||||||
|
Array.from(enabled).sort().join(',');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="rounded-xl border border-dashed border-border bg-surface p-4">
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
<strong>Cookie categories.</strong> Untick any category this site doesn’t use — it
|
||||||
|
will be hidden from the banner and permanently unconsented, so any cookie that falls
|
||||||
|
into it stays blocked. <em>Necessary</em> is always on and can’t be disabled.
|
||||||
|
{isInherited && (
|
||||||
|
<> This site is currently <strong>inheriting</strong> its category list from the
|
||||||
|
cascade (group → organisation → system default).</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">
|
||||||
|
Categories shown in the banner
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ALL_COOKIE_CATEGORIES.map((cat) => {
|
||||||
|
const active = enabled.has(cat.slug);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={cat.slug}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-copper bg-copper/5'
|
||||||
|
: 'border-border bg-transparent hover:bg-surface'
|
||||||
|
} ${cat.locked ? 'cursor-not-allowed opacity-80' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1"
|
||||||
|
checked={active}
|
||||||
|
disabled={cat.locked}
|
||||||
|
onChange={() => toggle(cat.slug, cat.locked)}
|
||||||
|
aria-labelledby={`cat-${cat.slug}-label`}
|
||||||
|
aria-describedby={`cat-${cat.slug}-desc`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
id={`cat-${cat.slug}-label`}
|
||||||
|
className="font-heading text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
</span>
|
||||||
|
{cat.locked && (
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
|
||||||
|
Always on
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p id={`cat-${cat.slug}-desc`} className="mt-1 text-xs text-text-secondary">
|
||||||
|
{cat.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{saved && <Alert variant="success">Categories saved.</Alert>}
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert variant="error">
|
||||||
|
Couldn’t save: {(mutation.error as Error)?.message ?? 'unknown error'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={mutation.isPending || (!dirty && !isInherited)}>
|
||||||
|
{mutation.isPending ? 'Saving…' : 'Save categories'}
|
||||||
|
</Button>
|
||||||
|
{!isInherited && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleResetToInherited}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Reset to inherited
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allActive && !isInherited && (
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
All five categories enabled — same as the system default.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
apps/admin-ui/src/components/SiteConsentTab.tsx
Normal file
235
apps/admin-ui/src/components/SiteConsentTab.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { listConsentRecords } from '../api/consent';
|
||||||
|
import type { ConsentRecord } from '../types/api';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
import { LoadingState } from './ui/loading-state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionVariant(action: string): 'success' | 'error' | 'warning' | 'neutral' {
|
||||||
|
const map: Record<string, 'success' | 'error' | 'warning'> = {
|
||||||
|
accept_all: 'success',
|
||||||
|
reject_all: 'error',
|
||||||
|
custom: 'warning',
|
||||||
|
withdraw: 'error',
|
||||||
|
};
|
||||||
|
return map[action] ?? 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
accept_all: 'Accept all',
|
||||||
|
reject_all: 'Reject all',
|
||||||
|
custom: 'Custom',
|
||||||
|
withdraw: 'Withdrawn',
|
||||||
|
};
|
||||||
|
return map[action] ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordDetail({ record }: { record: ConsentRecord }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="bg-mist px-4 py-3">
|
||||||
|
<div className="grid gap-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Visitor ID</span>
|
||||||
|
<p className="mt-0.5 break-all font-mono">{record.visitor_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Page URL</span>
|
||||||
|
<p className="mt-0.5 break-all">{record.page_url ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Accepted</span>
|
||||||
|
<p className="mt-0.5">{record.categories_accepted.join(', ') || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Rejected</span>
|
||||||
|
<p className="mt-0.5">{record.categories_rejected?.join(', ') || '—'}</p>
|
||||||
|
</div>
|
||||||
|
{record.country_code && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Location</span>
|
||||||
|
<p className="mt-0.5">{record.region_code ? `${record.country_code}-${record.region_code}` : record.country_code}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.tc_string && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">TC String</span>
|
||||||
|
<p className="mt-0.5 break-all font-mono text-[11px]">{record.tc_string}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.gpc_detected != null && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">GPC</span>
|
||||||
|
<p className="mt-0.5">
|
||||||
|
Detected: {record.gpc_detected ? 'Yes' : 'No'}
|
||||||
|
{record.gpc_honoured != null && ` · Honoured: ${record.gpc_honoured ? 'Yes' : 'No'}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteConsentTab({ siteId }: Props) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeSearch, setActiveSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['consent', siteId, activeSearch, page],
|
||||||
|
queryFn: () =>
|
||||||
|
listConsentRecords(siteId, {
|
||||||
|
visitor_id: activeSearch || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setActiveSearch(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Search */}
|
||||||
|
<Card className="mb-6 p-5">
|
||||||
|
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">
|
||||||
|
Search Consent Records
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
|
||||||
|
placeholder="Search by visitor ID..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch}>Search</Button>
|
||||||
|
{activeSearch && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setActiveSearch('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeSearch && (
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
Showing results for visitor: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{activeSearch}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingState message="Loading consent records..." />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-text-secondary">
|
||||||
|
{activeSearch
|
||||||
|
? 'No consent records found for this visitor.'
|
||||||
|
: 'No consent records yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs text-text-secondary">
|
||||||
|
<span>{data.total} record{data.total !== 1 ? 's' : ''}</span>
|
||||||
|
<span>Page {page} of {totalPages}</span>
|
||||||
|
</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">Visitor</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Action</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Categories</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Date</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{data.items.map((record) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={record.id}
|
||||||
|
className="cursor-pointer hover:bg-mist"
|
||||||
|
onClick={() => setExpandedId(expandedId === record.id ? null : record.id)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{record.visitor_id.length > 16
|
||||||
|
? record.visitor_id.slice(0, 8) + '…' + record.visitor_id.slice(-8)
|
||||||
|
: record.visitor_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={actionVariant(record.action)}>
|
||||||
|
{actionLabel(record.action)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{record.categories_accepted.join(', ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{new Date(record.consented_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-tertiary">
|
||||||
|
{expandedId === record.id ? '▲' : '▼'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedId === record.id && (
|
||||||
|
<RecordDetail key={`${record.id}-detail`} record={record} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { MetricCard } from './ui/metric-card';
|
import { MetricCard } from './ui/metric-card';
|
||||||
import type { Site, SiteConfig } from '../types/api';
|
import type { Site, SiteConfig } from '../types/api';
|
||||||
@@ -8,7 +10,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SiteOverviewTab({ site, config }: Props) {
|
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>`;
|
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}"></script>`;
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -36,17 +39,38 @@ export default function SiteOverviewTab({ site, config }: Props) {
|
|||||||
<p className="mb-3 text-sm text-text-secondary">
|
<p className="mb-3 text-sm text-text-secondary">
|
||||||
Add this script tag to the {'<head>'} of your website, before any other scripts.
|
Add this script tag to the {'<head>'} of your website, before any other scripts.
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="flex items-stretch">
|
||||||
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
|
<input
|
||||||
{scriptTag}
|
type="text"
|
||||||
</pre>
|
readOnly
|
||||||
|
value={scriptTag}
|
||||||
|
className="block w-full min-w-0 rounded-l-lg border border-r-0 border-border bg-mist px-3 py-2.5 font-mono text-xs text-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(scriptTag)}
|
type="button"
|
||||||
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(scriptTag).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex shrink-0 items-center gap-2 rounded-r-lg border border-copper bg-copper px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-copper/90 focus:outline-none focus:ring-2 focus:ring-copper/50"
|
||||||
>
|
>
|
||||||
Copy
|
{copied ? (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-3 5h3m-6 0h.01M12 16h3m-6 0h.01M10 3v4h4V3h-4Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
Must be the first {'<script>'} in {'<head>'} — no <code>async</code> or <code>defer</code>.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
|
|||||||
@@ -2,17 +2,34 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
|
|
||||||
import { getScan, getScanDiff, listScans, triggerScan } from '../api/scanner';
|
import { getScan, getScanDiff, listScans, triggerScan } from '../api/scanner';
|
||||||
|
import { getSiteConfig, updateSiteConfig } from '../api/sites';
|
||||||
import { trackFeatureUsage } from '../services/analytics';
|
import { trackFeatureUsage } from '../services/analytics';
|
||||||
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult } from '../types/api';
|
import type { CookieDiffItem, ScanDiff, ScanJob, ScanJobDetail, ScanResult, SiteConfig } from '../types/api';
|
||||||
import { Alert } from './ui/alert';
|
import { Alert } from './ui/alert';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
import { LoadingState } from './ui/loading-state';
|
import { LoadingState } from './ui/loading-state';
|
||||||
|
import { Select } from './ui/select';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEDULE_OPTIONS: { value: string; label: string; cron: string | null }[] = [
|
||||||
|
{ value: 'disabled', label: 'Disabled', cron: null },
|
||||||
|
{ value: 'daily', label: 'Daily', cron: '0 3 * * *' },
|
||||||
|
{ value: 'weekly', label: 'Weekly', cron: '0 3 * * 0' },
|
||||||
|
{ value: 'fortnightly', label: 'Fortnightly', cron: '0 3 1,15 * *' },
|
||||||
|
{ value: 'monthly', label: 'Monthly', cron: '0 3 1 * *' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cronToScheduleValue(cron: string | null | undefined): string {
|
||||||
|
if (!cron) return 'disabled';
|
||||||
|
const match = SCHEDULE_OPTIONS.find((o) => o.cron === cron);
|
||||||
|
return match?.value ?? 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
function statusVariant(status: string): 'warning' | 'info' | 'success' | 'error' | 'neutral' {
|
function statusVariant(status: string): 'warning' | 'info' | 'success' | 'error' | 'neutral' {
|
||||||
const map: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
|
const map: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
|
||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
@@ -183,6 +200,45 @@ export default function SiteScannerTab({ siteId }: Props) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedScanId, setExpandedScanId] = useState<string | null>(null);
|
const [expandedScanId, setExpandedScanId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: config } = useQuery<SiteConfig>({
|
||||||
|
queryKey: ['sites', siteId, 'config'],
|
||||||
|
queryFn: () => getSiteConfig(siteId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentCron = config?.scan_schedule_cron ?? null;
|
||||||
|
const savedValue = cronToScheduleValue(currentCron);
|
||||||
|
const [selectedSchedule, setSelectedSchedule] = useState<string | null>(null);
|
||||||
|
const [customCron, setCustomCron] = useState('');
|
||||||
|
|
||||||
|
// Use local selection if the user has interacted, otherwise fall
|
||||||
|
// back to what's saved on the server.
|
||||||
|
const activeValue = selectedSchedule ?? savedValue;
|
||||||
|
const showCustomInput = activeValue === 'custom';
|
||||||
|
|
||||||
|
const scheduleMutation = useMutation({
|
||||||
|
mutationFn: (cron: string | null) => updateSiteConfig(siteId, { scan_schedule_cron: cron } as Partial<SiteConfig>),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sites', siteId, 'config'] });
|
||||||
|
trackFeatureUsage('scan', 'schedule_change', { site_id: siteId });
|
||||||
|
setSelectedSchedule(null); // reset to server state
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScheduleChange = (value: string) => {
|
||||||
|
setSelectedSchedule(value);
|
||||||
|
if (value === 'custom') {
|
||||||
|
setCustomCron(currentCron ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = SCHEDULE_OPTIONS.find((o) => o.value === value);
|
||||||
|
scheduleMutation.mutate(option?.cron ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomSave = () => {
|
||||||
|
const trimmed = customCron.trim();
|
||||||
|
scheduleMutation.mutate(trimmed || null);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: scans, isLoading } = useQuery<ScanJob[]>({
|
const { data: scans, isLoading } = useQuery<ScanJob[]>({
|
||||||
queryKey: ['scans', siteId],
|
queryKey: ['scans', siteId],
|
||||||
queryFn: () => listScans(siteId),
|
queryFn: () => listScans(siteId),
|
||||||
@@ -202,6 +258,64 @@ export default function SiteScannerTab({ siteId }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Scan schedule */}
|
||||||
|
<Card className="mb-6 p-5">
|
||||||
|
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">Scan Schedule</h3>
|
||||||
|
<p className="mb-3 text-xs text-text-secondary">
|
||||||
|
Scheduled scans run automatically and re-discover cookies so your inventory stays
|
||||||
|
current. Select a preset or enter a custom cron expression.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-[180px]">
|
||||||
|
<Select
|
||||||
|
value={activeValue}
|
||||||
|
onChange={(e) => handleScheduleChange(e.target.value)}
|
||||||
|
disabled={scheduleMutation.isPending}
|
||||||
|
>
|
||||||
|
{SCHEDULE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Custom cron</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{showCustomInput && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
|
||||||
|
placeholder="0 3 * * 0"
|
||||||
|
value={customCron}
|
||||||
|
onChange={(e) => setCustomCron(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCustomSave}
|
||||||
|
disabled={scheduleMutation.isPending || !customCron.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href="https://crontab.guru"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-copper hover:underline"
|
||||||
|
>
|
||||||
|
Need help? Use crontab.guru →
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scheduleMutation.isPending && (
|
||||||
|
<span className="text-xs text-text-secondary">Saving…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentCron && (
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
Current schedule: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{currentCron}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Header with trigger button */}
|
{/* Header with trigger button */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-heading text-lg font-semibold text-foreground">Cookie Scans</h2>
|
<h2 className="font-heading text-lg font-semibold text-foreground">Cookie Scans</h2>
|
||||||
|
|||||||
178
apps/admin-ui/src/pages/AccountPage.tsx
Normal file
178
apps/admin-ui/src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { changePassword, getProfile, updateProfile } from '../api/auth';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: profile, isLoading } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: getProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Profile form
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [profileInit, setProfileInit] = useState(false);
|
||||||
|
const [profileSaved, setProfileSaved] = useState(false);
|
||||||
|
|
||||||
|
if (profile && !profileInit) {
|
||||||
|
setEmail(profile.email);
|
||||||
|
setFullName(profile.full_name);
|
||||||
|
setProfileInit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileMutation = useMutation({
|
||||||
|
mutationFn: (body: { email?: string; full_name?: string }) => updateProfile(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
|
setProfileSaved(true);
|
||||||
|
setTimeout(() => setProfileSaved(false), 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleProfileSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body: { email?: string; full_name?: string } = {};
|
||||||
|
if (email !== profile?.email) body.email = email;
|
||||||
|
if (fullName !== profile?.full_name) body.full_name = fullName;
|
||||||
|
if (Object.keys(body).length === 0) return;
|
||||||
|
profileMutation.mutate(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordSaved, setPasswordSaved] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
|
const passwordMutation = useMutation({
|
||||||
|
mutationFn: (body: { current_password: string; new_password: string }) => changePassword(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordError('');
|
||||||
|
setPasswordSaved(true);
|
||||||
|
setTimeout(() => setPasswordSaved(false), 2000);
|
||||||
|
},
|
||||||
|
onError: (err: Error & { response?: { data?: { detail?: string } } }) => {
|
||||||
|
setPasswordError(err.response?.data?.detail ?? 'Failed to change password');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePasswordSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPasswordError('');
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setPasswordError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordMutation.mutate({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="py-12 text-center text-sm text-text-secondary">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Account</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Manage your profile and password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile */}
|
||||||
|
<form onSubmit={handleProfileSubmit} className="mb-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Profile</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField label="Email">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Display name">
|
||||||
|
<Input
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
{profileSaved && <Alert variant="success" className="mt-4">Profile updated.</Alert>}
|
||||||
|
{profileMutation.isError && (
|
||||||
|
<Alert variant="error" className="mt-4">
|
||||||
|
{(profileMutation.error as Error & { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to update profile'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button type="submit" disabled={profileMutation.isPending}>
|
||||||
|
{profileMutation.isPending ? 'Saving...' : 'Save profile'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<form onSubmit={handlePasswordSubmit}>
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Change password</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField label="Current password">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="New password">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Confirm new password">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
{passwordSaved && <Alert variant="success" className="mt-4">Password changed.</Alert>}
|
||||||
|
{passwordError && <Alert variant="error" className="mt-4">{passwordError}</Alert>}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button type="submit" disabled={passwordMutation.isPending}>
|
||||||
|
{passwordMutation.isPending ? 'Changing...' : 'Change password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/admin-ui/src/pages/ConsentRecordsPage.tsx
Normal file
55
apps/admin-ui/src/pages/ConsentRecordsPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { listSites } from '../api/sites';
|
||||||
|
import SiteConsentTab from '../components/SiteConsentTab';
|
||||||
|
import { Select } from '../components/ui/select';
|
||||||
|
import type { Site } from '../types/api';
|
||||||
|
|
||||||
|
export default function ConsentRecordsPage() {
|
||||||
|
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
|
||||||
|
|
||||||
|
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
|
||||||
|
queryKey: ['sites'],
|
||||||
|
queryFn: listSites,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
|
||||||
|
Consent Records
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">
|
||||||
|
View and search consent records across your sites.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 max-w-xs">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Site
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedSiteId}
|
||||||
|
onChange={(e) => setSelectedSiteId(e.target.value)}
|
||||||
|
disabled={sitesLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a site…</option>
|
||||||
|
{sites?.map((site) => (
|
||||||
|
<option key={site.id} value={site.id}>
|
||||||
|
{site.display_name ?? site.domain}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSiteId ? (
|
||||||
|
<SiteConsentTab siteId={selectedSiteId} />
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-text-secondary">
|
||||||
|
Select a site to view its consent records.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
||||||
import SiteComplianceTab from '../components/SiteComplianceTab';
|
import SiteCategoriesTab from '../components/SiteCategoriesTab';
|
||||||
import SiteConfigTab from '../components/SiteConfigTab';
|
import SiteConfigTab from '../components/SiteConfigTab';
|
||||||
import SiteCookiesTab from '../components/SiteCookiesTab';
|
import SiteCookiesTab from '../components/SiteCookiesTab';
|
||||||
import SiteOverviewTab from '../components/SiteOverviewTab';
|
import SiteOverviewTab from '../components/SiteOverviewTab';
|
||||||
@@ -16,16 +16,24 @@ import { getSiteDetailTabs } from '../extensions/registry';
|
|||||||
const CORE_TABS: { id: string; label: string; order: number }[] = [
|
const CORE_TABS: { id: string; label: string; order: number }[] = [
|
||||||
{ id: 'overview', label: 'Overview', order: 10 },
|
{ id: 'overview', label: 'Overview', order: 10 },
|
||||||
{ id: 'config', label: 'Configuration', order: 20 },
|
{ id: 'config', label: 'Configuration', order: 20 },
|
||||||
|
{ id: 'categories', label: 'Categories', order: 25 },
|
||||||
{ id: 'cookies', label: 'Cookies', order: 30 },
|
{ id: 'cookies', label: 'Cookies', order: 30 },
|
||||||
{ id: 'banner', label: 'Banner', order: 40 },
|
{ id: 'banner', label: 'Banner', order: 40 },
|
||||||
{ id: 'translations', label: 'Translations', order: 50 },
|
{ id: 'translations', label: 'Translations', order: 50 },
|
||||||
{ id: 'scanner', label: 'Scans', order: 60 },
|
{ id: 'scanner', label: 'Scans', order: 60 },
|
||||||
{ id: 'compliance', label: 'Compliance', order: 70 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SiteDetailPage() {
|
export default function SiteDetailPage() {
|
||||||
const { siteId } = useParams<{ siteId: string }>();
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Persist the active tab in the URL hash so a page refresh restores it.
|
||||||
|
const activeTab = location.hash.replace('#', '') || 'overview';
|
||||||
|
const setActiveTab = useCallback(
|
||||||
|
(tab: string) => navigate({ hash: tab }, { replace: true }),
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
|
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
|
||||||
const allTabs = useMemo(() => {
|
const allTabs = useMemo(() => {
|
||||||
@@ -89,6 +97,9 @@ export default function SiteDetailPage() {
|
|||||||
{/* Tab content — core tabs */}
|
{/* Tab content — core tabs */}
|
||||||
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
|
{activeTab === 'overview' && <SiteOverviewTab site={site} config={config ?? null} />}
|
||||||
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
|
{activeTab === 'config' && siteId && <SiteConfigTab siteId={siteId} config={config ?? null} />}
|
||||||
|
{activeTab === 'categories' && siteId && (
|
||||||
|
<SiteCategoriesTab siteId={siteId} config={config ?? null} />
|
||||||
|
)}
|
||||||
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
|
{activeTab === 'cookies' && siteId && <SiteCookiesTab siteId={siteId} />}
|
||||||
{activeTab === 'banner' && siteId && (
|
{activeTab === 'banner' && siteId && (
|
||||||
<BannerBuilderTab
|
<BannerBuilderTab
|
||||||
@@ -100,7 +111,6 @@ export default function SiteDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'translations' && siteId && <SiteTranslationsTab siteId={siteId} />}
|
{activeTab === 'translations' && siteId && <SiteTranslationsTab siteId={siteId} />}
|
||||||
{activeTab === 'scanner' && siteId && <SiteScannerTab siteId={siteId} />}
|
{activeTab === 'scanner' && siteId && <SiteScannerTab siteId={siteId} />}
|
||||||
{activeTab === 'compliance' && siteId && <SiteComplianceTab siteId={siteId} config={config ?? null} />}
|
|
||||||
{/* Extension tabs */}
|
{/* Extension tabs */}
|
||||||
{extensionTabs.map(
|
{extensionTabs.map(
|
||||||
(ext) =>
|
(ext) =>
|
||||||
|
|||||||
139
apps/admin-ui/src/test/SiteCategoriesTab.test.tsx
Normal file
139
apps/admin-ui/src/test/SiteCategoriesTab.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import SiteCategoriesTab from '../components/SiteCategoriesTab';
|
||||||
|
import type { SiteConfig } from '../types/api';
|
||||||
|
|
||||||
|
vi.mock('../api/sites', () => ({
|
||||||
|
updateSiteConfig: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/analytics', () => ({
|
||||||
|
trackConfigChange: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { updateSiteConfig } from '../api/sites';
|
||||||
|
|
||||||
|
function renderWithProviders(ui: ReactNode) {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={client}>{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: null,
|
||||||
|
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,
|
||||||
|
scan_schedule_cron: null,
|
||||||
|
enabled_categories: null,
|
||||||
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
|
updated_at: '2025-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SiteCategoriesTab', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all five categories with necessary locked', () => {
|
||||||
|
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Functional/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Marketing/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
|
||||||
|
expect(necessary).toBeChecked();
|
||||||
|
expect(necessary).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "inheriting" copy when config has no override', () => {
|
||||||
|
renderWithProviders(<SiteCategoriesTab siteId="site-1" config={BASE_CONFIG} />);
|
||||||
|
expect(screen.getByText(/inheriting/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills from existing override', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<SiteCategoriesTab
|
||||||
|
siteId="site-1"
|
||||||
|
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Necessary/i })).toBeChecked();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Analytics/i })).toBeChecked();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Functional/i })).not.toBeChecked();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Marketing/i })).not.toBeChecked();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /Personalisation/i })).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves an explicit category list on submit', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<SiteCategoriesTab
|
||||||
|
siteId="site-1"
|
||||||
|
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics', 'marketing'] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop marketing
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: /Marketing/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /Save categories/i }));
|
||||||
|
|
||||||
|
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
|
||||||
|
enabled_categories: ['necessary', 'analytics'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to unlock necessary', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<SiteCategoriesTab
|
||||||
|
siteId="site-1"
|
||||||
|
config={{ ...BASE_CONFIG, enabled_categories: ['necessary', 'analytics'] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clicking the locked checkbox is a no-op
|
||||||
|
const necessary = screen.getByRole('checkbox', { name: /Necessary/i });
|
||||||
|
await user.click(necessary);
|
||||||
|
expect(necessary).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to inherited by sending null', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<SiteCategoriesTab
|
||||||
|
siteId="site-1"
|
||||||
|
config={{ ...BASE_CONFIG, enabled_categories: ['necessary'] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Reset to inherited/i }));
|
||||||
|
|
||||||
|
expect(updateSiteConfig).toHaveBeenCalledWith('site-1', {
|
||||||
|
enabled_categories: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ const BASE_CONFIG: SiteConfig = {
|
|||||||
scan_enabled: true,
|
scan_enabled: true,
|
||||||
scan_frequency_hours: 168,
|
scan_frequency_hours: 168,
|
||||||
scan_max_pages: 50,
|
scan_max_pages: 50,
|
||||||
|
scan_schedule_cron: null,
|
||||||
|
enabled_categories: null,
|
||||||
created_at: '2025-01-01T00:00:00Z',
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
updated_at: '2025-01-01T00:00:00Z',
|
updated_at: '2025-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,10 +129,63 @@ export interface SiteConfig {
|
|||||||
scan_enabled: boolean;
|
scan_enabled: boolean;
|
||||||
scan_frequency_hours: number;
|
scan_frequency_hours: number;
|
||||||
scan_max_pages: number;
|
scan_max_pages: number;
|
||||||
|
scan_schedule_cron: string | null;
|
||||||
|
/**
|
||||||
|
* Cookie categories the banner should display. ``null`` means
|
||||||
|
* "inherit from the cascade" (group → org → system default of all
|
||||||
|
* five). An explicit list overrides; ``necessary`` is always
|
||||||
|
* implicit and re-added by the resolver if missing.
|
||||||
|
*/
|
||||||
|
enabled_categories: CategorySlug[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CategorySlug =
|
||||||
|
| 'necessary'
|
||||||
|
| 'functional'
|
||||||
|
| 'analytics'
|
||||||
|
| 'marketing'
|
||||||
|
| 'personalisation';
|
||||||
|
|
||||||
|
export const ALL_COOKIE_CATEGORIES: {
|
||||||
|
slug: CategorySlug;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
locked: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
slug: 'necessary',
|
||||||
|
label: 'Necessary',
|
||||||
|
description: 'Essential for the website to function. Always active and cannot be disabled.',
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'functional',
|
||||||
|
label: 'Functional',
|
||||||
|
description: 'Remember preferences and enable enhanced features (e.g. language, chat widgets).',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
description: 'Measure traffic and interaction so you can understand how visitors use the site.',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'marketing',
|
||||||
|
label: 'Marketing',
|
||||||
|
description: 'Advertising, remarketing, and cross-site tracking.',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'personalisation',
|
||||||
|
label: 'Personalisation',
|
||||||
|
description: 'Tailor content, recommendations, and the banner appearance to the visitor.',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export interface ButtonConfig {
|
export interface ButtonConfig {
|
||||||
backgroundColour?: string;
|
backgroundColour?: string;
|
||||||
textColour?: string;
|
textColour?: string;
|
||||||
@@ -673,3 +726,28 @@ export interface ConsentReceiptResponse {
|
|||||||
banner_version_hash: string | null;
|
banner_version_hash: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConsentRecord {
|
||||||
|
id: string;
|
||||||
|
site_id: string;
|
||||||
|
visitor_id: string;
|
||||||
|
action: string;
|
||||||
|
categories_accepted: string[];
|
||||||
|
categories_rejected: string[] | null;
|
||||||
|
tc_string: string | null;
|
||||||
|
gcm_state: Record<string, string> | null;
|
||||||
|
gpp_string: string | null;
|
||||||
|
gpc_detected: boolean | null;
|
||||||
|
gpc_honoured: boolean | null;
|
||||||
|
page_url: string | null;
|
||||||
|
country_code: string | null;
|
||||||
|
region_code: string | null;
|
||||||
|
consented_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
from urllib.parse import urlparse, parse_qs, urlencode, unquote
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import create_engine, pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
|
|
||||||
# Alembic Config object
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# Override sqlalchemy.url from environment if set
|
raw_url = os.environ.get("DATABASE_URL", "")
|
||||||
database_url = os.environ.get("DATABASE_URL")
|
if raw_url:
|
||||||
if database_url:
|
# Convert async driver to sync driver
|
||||||
# Alembic needs the synchronous driver
|
url = raw_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||||
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://")
|
url = unquote(url)
|
||||||
config.set_main_option("sqlalchemy.url", database_url)
|
# Strip sslmode (not supported by psycopg2)
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.query:
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
params.pop("sslmode", None)
|
||||||
|
new_query = urlencode(params, doseq=True)
|
||||||
|
url = parsed._replace(query=new_query).geturl()
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
|
||||||
# Set up Python logging from the config file
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
@@ -24,7 +30,6 @@ target_metadata = Base.metadata
|
|||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode."""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
context.configure(
|
context.configure(
|
||||||
url=url,
|
url=url,
|
||||||
@@ -32,25 +37,26 @@ def run_migrations_offline() -> None:
|
|||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
def run_migrations_online() -> None:
|
||||||
"""Run migrations in 'online' mode."""
|
# Use DATABASE_URL env directly, properly converted for psycopg2
|
||||||
connectable = engine_from_config(
|
raw_url = os.environ.get("DATABASE_URL", "")
|
||||||
config.get_section(config.config_ini_section, {}),
|
url = raw_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||||
prefix="sqlalchemy.",
|
url = unquote(url)
|
||||||
poolclass=pool.NullPool,
|
# Strip sslmode
|
||||||
)
|
parsed = urlparse(url)
|
||||||
|
if parsed.query:
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
params.pop("sslmode", None)
|
||||||
|
new_query = urlencode(params, doseq=True)
|
||||||
|
url = parsed._replace(query=new_query).geturl()
|
||||||
|
|
||||||
|
connectable = create_engine(url, poolclass=pool.NullPool)
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|||||||
46
apps/api/alembic/versions/0003_enabled_categories.py
Normal file
46
apps/api/alembic/versions/0003_enabled_categories.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""enabled_categories on site / group / org configs
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
|
||||||
|
Per-site control over which cookie categories the banner displays.
|
||||||
|
Cascades the same way every other config setting does — site overrides
|
||||||
|
site-group overrides org overrides system default (all 5 categories).
|
||||||
|
|
||||||
|
Stored as JSONB rather than an array column so the resolver sees a
|
||||||
|
plain Python list via SQLAlchemy's JSONB codec and doesn't need
|
||||||
|
PostgreSQL-specific array handling in the merge logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: str | Sequence[str] | None = "0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_TABLES = ("site_configs", "site_group_configs", "org_configs")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"enabled_categories",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
op.drop_column(table, "enabled_categories")
|
||||||
@@ -2263,3 +2263,4 @@ c7d8e9f0-0012-4567-890a-000000000012,Plausible Analytics,Analytics,plausible_,,"
|
|||||||
c7d8e9f0-0013-4567-890a-000000000013,Fathom Analytics,Analytics,_fathom,,"Privacy-focused simple website analytics with minimal data collection.",Varies,Conva Ventures Inc,https://usefathom.com/privacy,1
|
c7d8e9f0-0013-4567-890a-000000000013,Fathom Analytics,Analytics,_fathom,,"Privacy-focused simple website analytics with minimal data collection.",Varies,Conva Ventures Inc,https://usefathom.com/privacy,1
|
||||||
c7d8e9f0-0014-4567-890a-000000000014,Umami,Analytics,umami.,,"Open-source privacy-friendly web analytics alternative.",Varies,Website operator,https://umami.is/docs/about,1
|
c7d8e9f0-0014-4567-890a-000000000014,Umami,Analytics,umami.,,"Open-source privacy-friendly web analytics alternative.",Varies,Website operator,https://umami.is/docs/about,1
|
||||||
c7d8e9f0-0015-4567-890a-000000000015,Vercel,Functional,_vercel_,,"Vercel platform cookies for deployment previews and analytics.",Varies,Vercel Inc,https://vercel.com/legal/privacy-policy,1
|
c7d8e9f0-0015-4567-890a-000000000015,Vercel,Functional,_vercel_,,"Vercel platform cookies for deployment previews and analytics.",Varies,Vercel Inc,https://vercel.com/legal/privacy-policy,1
|
||||||
|
c7d8e9f0-0016-4567-890a-000000000016,Google Ads,Marketing,_gcl_ls,,"Google Click Identifier for localStorage-based ad conversion tracking.",90 Days,Google,https://business.safety.google/privacy/,0
|
||||||
|
|||||||
|
Can't render this file because it is too large.
|
67
apps/api/src/cli/reset_password.py
Normal file
67
apps/api/src/cli/reset_password.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Reset a user's password from the command line.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker exec consentos-api python -m src.cli.reset_password \\
|
||||||
|
--email admin@example.com --password new-secret
|
||||||
|
|
||||||
|
For use when the password has been forgotten and the admin UI is
|
||||||
|
inaccessible. Connects directly to the database, so it must run
|
||||||
|
inside a container (or host) that can reach PostgreSQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sync_url(async_url: str) -> str:
|
||||||
|
return async_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||||
|
|
||||||
|
|
||||||
|
def reset(email: str, password: str) -> bool:
|
||||||
|
"""Reset the password for the given email. Returns True on success."""
|
||||||
|
from src.config.settings import get_settings
|
||||||
|
from src.services.auth import hash_password
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
engine = sa.create_engine(_build_sync_url(settings.database_url))
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM users WHERE email = :email AND deleted_at IS NULL"),
|
||||||
|
{"email": email},
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE users SET password_hash = :pw, updated_at = NOW() WHERE id = :id"),
|
||||||
|
{"pw": hash_password(password), "id": str(row[0])},
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Reset a user's password")
|
||||||
|
parser.add_argument("--email", required=True, help="User email address")
|
||||||
|
parser.add_argument("--password", required=True, help="New password")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if len(args.password) < 8:
|
||||||
|
print("Error: password must be at least 8 characters", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if reset(args.email, args.password):
|
||||||
|
print(f"Password reset for {args.email}")
|
||||||
|
else:
|
||||||
|
print(f"Error: no active user found with email {args.email}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -117,6 +117,40 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_enabled: bool = True
|
rate_limit_enabled: bool = True
|
||||||
rate_limit_per_minute: int = 120
|
rate_limit_per_minute: int = 120
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _normalize_database_url(self) -> "Settings":
|
||||||
|
"""Auto-fix common database URL schemes for asyncpg compatibility.
|
||||||
|
|
||||||
|
Platforms like Easypanel emit DATABASE_URL as ``postgres://...``
|
||||||
|
(shortcut or legacy scheme). SQLAlchemy expects the dialect name
|
||||||
|
``postgresql://`` and we need the ``+asyncpg`` driver suffix for
|
||||||
|
the async engine. Normalise both cases here so the rest of the
|
||||||
|
codebase can always assume ``postgresql+asyncpg://``.
|
||||||
|
|
||||||
|
Also strips ``sslmode`` from query strings — asyncpg does not
|
||||||
|
accept this psycopg2 parameter and would raise TypeError.
|
||||||
|
"""
|
||||||
|
url = self.database_url
|
||||||
|
|
||||||
|
# Fix dialect scheme
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
url = url.replace("postgres://", "postgresql+asyncpg://", 1)
|
||||||
|
elif url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
|
||||||
|
# Strip sslmode from query string (asyncpg doesn't support it)
|
||||||
|
if "?sslmode=" in url or "&sslmode=" in url:
|
||||||
|
from urllib.parse import urlparse, urlencode, parse_qs
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params.pop("sslmode", None)
|
||||||
|
query = urlencode(params, doseq=True)
|
||||||
|
url = parsed._replace(query=query).geturl()
|
||||||
|
|
||||||
|
self.database_url = url
|
||||||
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_production_safety(self) -> "Settings":
|
def _check_production_safety(self) -> "Settings":
|
||||||
"""Refuse to start with unsafe defaults in non-dev environments."""
|
"""Refuse to start with unsafe defaults in non-dev environments."""
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class OrgConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Cookie categories shown in the banner. NULL = inherit (system
|
||||||
|
# default is all five). See ``SiteConfig.enabled_categories`` for
|
||||||
|
# the full cascade semantics.
|
||||||
|
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
# Scanning
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class SiteConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Cookie categories shown in the banner. When NULL, inherit from the
|
||||||
|
# cascade (site-group → org → system default of all five). An explicit
|
||||||
|
# list overrides. ``necessary`` is always implicit and will be forced
|
||||||
|
# back into the merged result by the resolver, so operators can't
|
||||||
|
# accidentally drop it.
|
||||||
|
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
# Scanning
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
scan_max_pages: Mapped[int] = mapped_column(Integer, server_default="50", nullable=False)
|
scan_max_pages: Mapped[int] = mapped_column(Integer, server_default="50", nullable=False)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class SiteGroupConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
terms_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Cookie categories shown in the banner. NULL = inherit (system
|
||||||
|
# default is all five). See ``SiteConfig.enabled_categories`` for
|
||||||
|
# the full cascade semantics.
|
||||||
|
enabled_categories: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
# Scanning
|
# Scanning
|
||||||
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
scan_schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
scan_max_pages: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.config.settings import get_settings
|
from src.config.settings import get_settings
|
||||||
from src.db import get_db
|
from src.db import get_db
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.auth import CurrentUser, LoginRequest, RefreshRequest, TokenResponse
|
from src.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
CurrentUser,
|
||||||
|
LoginRequest,
|
||||||
|
ProfileResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
)
|
||||||
from src.services.auth import (
|
from src.services.auth import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
decode_token,
|
decode_token,
|
||||||
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from src.services.dependencies import get_current_user
|
from src.services.dependencies import get_current_user
|
||||||
@@ -102,7 +111,74 @@ async def refresh(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=CurrentUser)
|
@router.get("/me", response_model=ProfileResponse)
|
||||||
async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
async def get_me(
|
||||||
"""Return the currently authenticated user's profile from the JWT."""
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
return current_user
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Return the currently authenticated user's profile."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=ProfileResponse)
|
||||||
|
async def update_profile(
|
||||||
|
body: UpdateProfileRequest,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Update the current user's email or display name."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
if body.email is not None:
|
||||||
|
# Check uniqueness
|
||||||
|
existing = await db.execute(
|
||||||
|
select(User).where(User.email == body.email, User.id != current_user.id)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Email already in use",
|
||||||
|
)
|
||||||
|
user.email = body.email
|
||||||
|
|
||||||
|
if body.full_name is not None:
|
||||||
|
user.full_name = body.full_name
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""Change the current user's password. Requires the current password."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == current_user.id, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
if not verify_password(body.current_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Current password is incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
await db.flush()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.db import get_db
|
from src.db import get_db
|
||||||
@@ -11,6 +11,7 @@ from src.models.site import Site
|
|||||||
from src.schemas.auth import CurrentUser
|
from src.schemas.auth import CurrentUser
|
||||||
from src.schemas.consent import (
|
from src.schemas.consent import (
|
||||||
ConsentRecordCreate,
|
ConsentRecordCreate,
|
||||||
|
ConsentRecordListResponse,
|
||||||
ConsentRecordResponse,
|
ConsentRecordResponse,
|
||||||
ConsentVerifyResponse,
|
ConsentVerifyResponse,
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,63 @@ async def _load_record_for_org(
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ConsentRecordListResponse)
|
||||||
|
async def list_consent_records(
|
||||||
|
site_id: uuid.UUID = Query(..., description="Filter by site"),
|
||||||
|
visitor_id: str | None = Query(None, description="Filter by visitor ID (exact match)"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""List consent records for a site, with optional visitor_id filter.
|
||||||
|
|
||||||
|
Tenant-isolated — the site must belong to the caller's organisation.
|
||||||
|
Returns newest records first.
|
||||||
|
"""
|
||||||
|
# Verify site belongs to the caller's org.
|
||||||
|
site = (
|
||||||
|
await db.execute(
|
||||||
|
select(Site).where(
|
||||||
|
Site.id == site_id,
|
||||||
|
Site.organisation_id == current_user.organisation_id,
|
||||||
|
Site.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if site is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||||
|
|
||||||
|
base = select(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
||||||
|
count_base = (
|
||||||
|
select(func.count()).select_from(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if visitor_id:
|
||||||
|
base = base.where(ConsentRecord.visitor_id == visitor_id)
|
||||||
|
count_base = count_base.where(ConsentRecord.visitor_id == visitor_id)
|
||||||
|
|
||||||
|
total = await db.scalar(count_base) or 0
|
||||||
|
items = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
base.order_by(ConsentRecord.consented_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": list(items),
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
|
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
|
||||||
async def get_consent(
|
async def get_consent(
|
||||||
consent_id: uuid.UUID,
|
consent_id: uuid.UUID,
|
||||||
|
|||||||
@@ -29,6 +29,26 @@ class TokenPayload(BaseModel):
|
|||||||
type: str = "access" # "access" or "refresh"
|
type: str = "access" # "access" or "refresh"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileRequest(BaseModel):
|
||||||
|
email: EmailStr | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
full_name: str
|
||||||
|
role: str
|
||||||
|
organisation_id: uuid.UUID
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class CurrentUser(BaseModel):
|
class CurrentUser(BaseModel):
|
||||||
"""Represents the authenticated user extracted from a JWT."""
|
"""Represents the authenticated user extracted from a JWT."""
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class ConsentRecordResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentRecordListResponse(BaseModel):
|
||||||
|
"""Paginated list of consent records."""
|
||||||
|
|
||||||
|
items: list[ConsentRecordResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
class ConsentVerifyResponse(BaseModel):
|
class ConsentVerifyResponse(BaseModel):
|
||||||
"""Audit proof that a consent record exists."""
|
"""Audit proof that a consent record exists."""
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class OrgConfigUpdate(BaseModel):
|
|||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
||||||
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class OrgConfigResponse(BaseModel):
|
class OrgConfigResponse(BaseModel):
|
||||||
@@ -55,6 +56,7 @@ class OrgConfigResponse(BaseModel):
|
|||||||
scan_max_pages: int | None
|
scan_max_pages: int | None
|
||||||
consent_expiry_days: int | None
|
consent_expiry_days: int | None
|
||||||
consent_retention_days: int | None
|
consent_retention_days: int | None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class SiteConfigCreate(BaseModel):
|
|||||||
scan_max_pages: int = Field(default=50, ge=1, le=1000)
|
scan_max_pages: int = Field(default=50, ge=1, le=1000)
|
||||||
consent_expiry_days: int = Field(default=365, ge=1, le=730)
|
consent_expiry_days: int = Field(default=365, ge=1, le=730)
|
||||||
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
|
# None = inherit from the cascade (group → org → system). An
|
||||||
|
# explicit list overrides; the resolver re-adds ``necessary``
|
||||||
|
# if omitted and drops any unknown slugs.
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SiteConfigUpdate(BaseModel):
|
class SiteConfigUpdate(BaseModel):
|
||||||
@@ -87,6 +91,7 @@ class SiteConfigUpdate(BaseModel):
|
|||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
||||||
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
consent_retention_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SiteConfigResponse(BaseModel):
|
class SiteConfigResponse(BaseModel):
|
||||||
@@ -111,6 +116,7 @@ class SiteConfigResponse(BaseModel):
|
|||||||
scan_max_pages: int = 50
|
scan_max_pages: int = 50
|
||||||
consent_expiry_days: int = 365
|
consent_expiry_days: int = 365
|
||||||
consent_retention_days: int | None = None
|
consent_retention_days: int | None = None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class SiteGroupConfigUpdate(BaseModel):
|
|||||||
scan_schedule_cron: str | None = None
|
scan_schedule_cron: str | None = None
|
||||||
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
scan_max_pages: int | None = Field(default=None, ge=1, le=1000)
|
||||||
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
consent_expiry_days: int | None = Field(default=None, ge=1, le=730)
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SiteGroupConfigResponse(BaseModel):
|
class SiteGroupConfigResponse(BaseModel):
|
||||||
@@ -53,6 +54,7 @@ class SiteGroupConfigResponse(BaseModel):
|
|||||||
scan_schedule_cron: str | None
|
scan_schedule_cron: str | None
|
||||||
scan_max_pages: int | None
|
scan_max_pages: int | None
|
||||||
consent_expiry_days: int | None
|
consent_expiry_days: int | None
|
||||||
|
enabled_categories: list[str] | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,26 @@ def classify_cookie(
|
|||||||
|
|
||||||
This is a pure function — all data is passed in, no DB calls.
|
This is a pure function — all data is passed in, no DB calls.
|
||||||
"""
|
"""
|
||||||
|
# 0. ConsentOS's own cookies are always necessary. The banner's
|
||||||
|
# blocker already treats ``_consentos_*`` as exempt; the
|
||||||
|
# classifier must agree so the admin UI shows them in the
|
||||||
|
# right category without requiring a known-cookies DB entry.
|
||||||
|
if cookie_name.startswith("_consentos_"):
|
||||||
|
necessary = next(
|
||||||
|
(cat for cat in category_map.values() if cat.slug == "necessary"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return ClassificationResult(
|
||||||
|
cookie_name=cookie_name,
|
||||||
|
cookie_domain=cookie_domain,
|
||||||
|
category_id=necessary.id if necessary else None,
|
||||||
|
category_slug="necessary",
|
||||||
|
vendor="ConsentOS",
|
||||||
|
description="ConsentOS consent management cookie.",
|
||||||
|
match_source=MatchSource.KNOWN_EXACT,
|
||||||
|
matched=True,
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Check allow-list first (site-specific overrides)
|
# 1. Check allow-list first (site-specific overrides)
|
||||||
allow_match = _match_allow_list(cookie_name, cookie_domain, allow_list)
|
allow_match = _match_allow_list(cookie_name, cookie_domain, allow_list)
|
||||||
if allow_match:
|
if allow_match:
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# Every known cookie category, in the canonical display order the
|
||||||
|
# banner uses. The system default for ``enabled_categories`` is this
|
||||||
|
# full list; operators subset from the top via the cascade.
|
||||||
|
ALL_CATEGORIES: list[str] = [
|
||||||
|
"necessary",
|
||||||
|
"functional",
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
"personalisation",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ``necessary`` is never optional — operators can't hide it and the
|
||||||
|
# merged result always contains it, even if it's been accidentally
|
||||||
|
# dropped from every layer of the cascade.
|
||||||
|
REQUIRED_CATEGORIES: frozenset[str] = frozenset({"necessary"})
|
||||||
|
|
||||||
|
|
||||||
# System-level defaults (hard-coded, lowest priority)
|
# System-level defaults (hard-coded, lowest priority)
|
||||||
SYSTEM_DEFAULTS: dict[str, Any] = {
|
SYSTEM_DEFAULTS: dict[str, Any] = {
|
||||||
"blocking_mode": "opt_in",
|
"blocking_mode": "opt_in",
|
||||||
@@ -34,6 +51,10 @@ SYSTEM_DEFAULTS: dict[str, Any] = {
|
|||||||
"privacy_policy_url": None,
|
"privacy_policy_url": None,
|
||||||
"terms_url": None,
|
"terms_url": None,
|
||||||
"consent_expiry_days": 365,
|
"consent_expiry_days": 365,
|
||||||
|
# All five categories visible by default; any cascade layer may
|
||||||
|
# narrow this to a subset. The resolver normalises the result
|
||||||
|
# via ``_normalise_enabled_categories``.
|
||||||
|
"enabled_categories": ALL_CATEGORIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -77,9 +98,33 @@ def resolve_config(
|
|||||||
if regional_mode:
|
if regional_mode:
|
||||||
resolved["blocking_mode"] = regional_mode
|
resolved["blocking_mode"] = regional_mode
|
||||||
|
|
||||||
|
resolved["enabled_categories"] = _normalise_enabled_categories(
|
||||||
|
resolved.get("enabled_categories")
|
||||||
|
)
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_enabled_categories(value: Any) -> list[str]:
|
||||||
|
"""Clean a merged ``enabled_categories`` value into a canonical list.
|
||||||
|
|
||||||
|
- ``None`` / empty / invalid types fall back to the full default.
|
||||||
|
- Unknown slugs are stripped so a typo can't light up a category
|
||||||
|
the banner doesn't actually render.
|
||||||
|
- ``necessary`` is always forced into the output — required
|
||||||
|
categories can never be absent, regardless of what the operator
|
||||||
|
configured. The order mirrors ``ALL_CATEGORIES`` so the banner
|
||||||
|
renders tabs in a consistent order no matter the insertion order.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
return list(ALL_CATEGORIES)
|
||||||
|
|
||||||
|
known = set(ALL_CATEGORIES)
|
||||||
|
picked = {slug for slug in value if isinstance(slug, str) and slug in known}
|
||||||
|
picked.update(REQUIRED_CATEGORIES)
|
||||||
|
return [slug for slug in ALL_CATEGORIES if slug in picked]
|
||||||
|
|
||||||
|
|
||||||
def build_public_config(
|
def build_public_config(
|
||||||
site_id: str,
|
site_id: str,
|
||||||
resolved: dict[str, Any],
|
resolved: dict[str, Any],
|
||||||
@@ -108,6 +153,9 @@ def build_public_config(
|
|||||||
"consent_expiry_days": resolved["consent_expiry_days"],
|
"consent_expiry_days": resolved["consent_expiry_days"],
|
||||||
"consent_group_id": resolved.get("consent_group_id"),
|
"consent_group_id": resolved.get("consent_group_id"),
|
||||||
"ab_test": resolved.get("ab_test"),
|
"ab_test": resolved.get("ab_test"),
|
||||||
|
# Public name is ``enabled_categories`` here; the banner schema
|
||||||
|
# converts that to ``enabledCategories`` when it serialises.
|
||||||
|
"enabled_categories": _normalise_enabled_categories(resolved.get("enabled_categories")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +176,7 @@ CONFIG_FIELDS = (
|
|||||||
"privacy_policy_url",
|
"privacy_policy_url",
|
||||||
"terms_url",
|
"terms_url",
|
||||||
"consent_expiry_days",
|
"consent_expiry_days",
|
||||||
|
"enabled_categories",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from datetime import UTC, datetime
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.cookie import Cookie
|
from src.models.cookie import Cookie, CookieCategory
|
||||||
from src.models.scan import ScanJob, ScanResult
|
from src.models.scan import ScanJob, ScanResult
|
||||||
from src.models.site import Site
|
from src.models.site import Site
|
||||||
from src.schemas.scanner import (
|
from src.schemas.scanner import (
|
||||||
@@ -261,7 +261,13 @@ async def sync_scan_results_to_cookies(
|
|||||||
"""Upsert scan results into the site's cookie inventory.
|
"""Upsert scan results into the site's cookie inventory.
|
||||||
|
|
||||||
Creates new Cookie records for newly discovered items or updates
|
Creates new Cookie records for newly discovered items or updates
|
||||||
last_seen_at for existing ones. Returns the number of new cookies.
|
``last_seen_at`` for existing ones. When ``auto_category`` is set
|
||||||
|
on the scan result and the cookie doesn't already have a
|
||||||
|
manually-assigned category, the auto-classified category is
|
||||||
|
propagated to the cookie inventory so it shows up categorised in
|
||||||
|
the admin UI without requiring manual review.
|
||||||
|
|
||||||
|
Returns the number of new cookies.
|
||||||
"""
|
"""
|
||||||
results = await db.execute(select(ScanResult).where(ScanResult.scan_job_id == scan_job_id))
|
results = await db.execute(select(ScanResult).where(ScanResult.scan_job_id == scan_job_id))
|
||||||
items = list(results.scalars().all())
|
items = list(results.scalars().all())
|
||||||
@@ -269,6 +275,10 @@ async def sync_scan_results_to_cookies(
|
|||||||
now_iso = datetime.now(UTC).isoformat()
|
now_iso = datetime.now(UTC).isoformat()
|
||||||
new_count = 0
|
new_count = 0
|
||||||
|
|
||||||
|
# Pre-load the category slug → id mapping so we don't query per cookie.
|
||||||
|
cat_rows = await db.execute(select(CookieCategory))
|
||||||
|
slug_to_id: dict[str, uuid.UUID] = {cat.slug: cat.id for cat in cat_rows.scalars().all()}
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(Cookie).where(
|
select(Cookie).where(
|
||||||
@@ -280,14 +290,21 @@ async def sync_scan_results_to_cookies(
|
|||||||
)
|
)
|
||||||
cookie = existing.scalar_one_or_none()
|
cookie = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Resolve the auto-category slug to a category_id.
|
||||||
|
auto_cat_id = slug_to_id.get(item.auto_category) if item.auto_category else None
|
||||||
|
|
||||||
if cookie:
|
if cookie:
|
||||||
cookie.last_seen_at = now_iso
|
cookie.last_seen_at = now_iso
|
||||||
|
# Back-fill the category if not manually assigned yet.
|
||||||
|
if auto_cat_id and not cookie.category_id:
|
||||||
|
cookie.category_id = auto_cat_id
|
||||||
else:
|
else:
|
||||||
cookie = Cookie(
|
cookie = Cookie(
|
||||||
site_id=site_id,
|
site_id=site_id,
|
||||||
name=item.cookie_name,
|
name=item.cookie_name,
|
||||||
domain=item.cookie_domain,
|
domain=item.cookie_domain,
|
||||||
storage_type=item.storage_type,
|
storage_type=item.storage_type,
|
||||||
|
category_id=auto_cat_id,
|
||||||
review_status="pending",
|
review_status="pending",
|
||||||
first_seen_at=now_iso,
|
first_seen_at=now_iso,
|
||||||
last_seen_at=now_iso,
|
last_seen_at=now_iso,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import uuid
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from src.config.settings import get_settings
|
from src.config.settings import get_settings
|
||||||
@@ -140,7 +141,11 @@ class TestAuthEndpoints:
|
|||||||
response = await client.get("/api/v1/auth/me")
|
response = await client.get("/api/v1/auth/me")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
async def test_me_with_valid_token(self, client):
|
async def test_me_with_valid_token(self, app):
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from src.db import get_db
|
||||||
|
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
org_id = uuid.uuid4()
|
org_id = uuid.uuid4()
|
||||||
token = create_access_token(
|
token = create_access_token(
|
||||||
@@ -149,10 +154,34 @@ class TestAuthEndpoints:
|
|||||||
role="editor",
|
role="editor",
|
||||||
email="user@example.com",
|
email="user@example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.id = user_id
|
||||||
|
mock_user.organisation_id = org_id
|
||||||
|
mock_user.email = "user@example.com"
|
||||||
|
mock_user.full_name = "Test User"
|
||||||
|
mock_user.role = "editor"
|
||||||
|
mock_user.deleted_at = None
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
async def _override():
|
||||||
|
yield mock_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/auth/me",
|
"/api/v1/auth/me",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides.pop(get_db, None)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["id"] == str(user_id)
|
assert data["id"] == str(user_id)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.services.config_resolver import (
|
from src.services.config_resolver import (
|
||||||
|
ALL_CATEGORIES,
|
||||||
SYSTEM_DEFAULTS,
|
SYSTEM_DEFAULTS,
|
||||||
|
_normalise_enabled_categories,
|
||||||
build_public_config,
|
build_public_config,
|
||||||
resolve_config,
|
resolve_config,
|
||||||
)
|
)
|
||||||
@@ -256,3 +258,72 @@ class TestConfigRoutes:
|
|||||||
site_id = uuid.uuid4()
|
site_id = uuid.uuid4()
|
||||||
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
|
resp = await client.post(f"/api/v1/config/sites/{site_id}/publish")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnabledCategories:
|
||||||
|
"""Cascade semantics for ``enabled_categories``."""
|
||||||
|
|
||||||
|
def test_system_default_is_all_five(self):
|
||||||
|
result = resolve_config({})
|
||||||
|
assert result["enabled_categories"] == ALL_CATEGORIES
|
||||||
|
|
||||||
|
def test_site_override_narrows_system_default(self):
|
||||||
|
result = resolve_config({"enabled_categories": ["necessary", "analytics"]})
|
||||||
|
assert result["enabled_categories"] == ["necessary", "analytics"]
|
||||||
|
|
||||||
|
def test_site_override_beats_org_override(self):
|
||||||
|
result = resolve_config(
|
||||||
|
site_config={"enabled_categories": ["necessary", "marketing"]},
|
||||||
|
org_defaults={"enabled_categories": ["necessary", "analytics"]},
|
||||||
|
)
|
||||||
|
assert result["enabled_categories"] == ["necessary", "marketing"]
|
||||||
|
|
||||||
|
def test_group_override_beats_org_override_when_site_unset(self):
|
||||||
|
result = resolve_config(
|
||||||
|
site_config={},
|
||||||
|
org_defaults={"enabled_categories": ["necessary", "analytics"]},
|
||||||
|
group_defaults={"enabled_categories": ["necessary", "functional"]},
|
||||||
|
)
|
||||||
|
assert result["enabled_categories"] == ["necessary", "functional"]
|
||||||
|
|
||||||
|
def test_unset_site_inherits_org(self):
|
||||||
|
result = resolve_config(
|
||||||
|
site_config={},
|
||||||
|
org_defaults={"enabled_categories": ["necessary", "marketing"]},
|
||||||
|
)
|
||||||
|
assert result["enabled_categories"] == ["necessary", "marketing"]
|
||||||
|
|
||||||
|
def test_necessary_forced_in_when_missing_from_override(self):
|
||||||
|
"""Operators can't accidentally drop ``necessary``."""
|
||||||
|
result = resolve_config({"enabled_categories": ["analytics", "marketing"]})
|
||||||
|
assert "necessary" in result["enabled_categories"]
|
||||||
|
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
|
||||||
|
|
||||||
|
def test_unknown_slugs_are_stripped(self):
|
||||||
|
result = resolve_config({"enabled_categories": ["necessary", "spam", "analytics"]})
|
||||||
|
assert result["enabled_categories"] == ["necessary", "analytics"]
|
||||||
|
|
||||||
|
def test_empty_list_falls_back_to_default(self):
|
||||||
|
"""An empty list is treated as 'no categories configured' → default."""
|
||||||
|
result = resolve_config({"enabled_categories": []})
|
||||||
|
assert result["enabled_categories"] == ALL_CATEGORIES
|
||||||
|
|
||||||
|
def test_non_list_value_falls_back_to_default(self):
|
||||||
|
result = resolve_config({"enabled_categories": "not-a-list"}) # type: ignore[dict-item]
|
||||||
|
assert result["enabled_categories"] == ALL_CATEGORIES
|
||||||
|
|
||||||
|
def test_result_is_in_canonical_display_order(self):
|
||||||
|
"""Insertion order from the cascade must not leak into the output."""
|
||||||
|
result = resolve_config({"enabled_categories": ["marketing", "necessary", "analytics"]})
|
||||||
|
assert result["enabled_categories"] == ["necessary", "analytics", "marketing"]
|
||||||
|
|
||||||
|
def test_public_config_includes_enabled_categories(self):
|
||||||
|
resolved = resolve_config({"enabled_categories": ["necessary", "analytics"]})
|
||||||
|
public = build_public_config("site-xyz", resolved)
|
||||||
|
assert public["enabled_categories"] == ["necessary", "analytics"]
|
||||||
|
|
||||||
|
def test_normalise_handles_none(self):
|
||||||
|
assert _normalise_enabled_categories(None) == ALL_CATEGORIES
|
||||||
|
|
||||||
|
def test_normalise_preserves_explicit_full_list(self):
|
||||||
|
assert _normalise_enabled_categories(list(ALL_CATEGORIES)) == ALL_CATEGORIES
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ class TestMeEndpoint:
|
|||||||
role="owner",
|
role="owner",
|
||||||
email="admin@test.com",
|
email="admin@test.com",
|
||||||
)
|
)
|
||||||
db = _mock_db()
|
mock_user = _make_user(id=user_id, org_id=org_id, email="admin@test.com", role="owner")
|
||||||
|
db = _mock_db(scalar_one_or_none=mock_user)
|
||||||
async with await _client(mock_app, db) as client:
|
async with await _client(mock_app, db) as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/v1/auth/me",
|
"/api/v1/auth/me",
|
||||||
|
|||||||
116
apps/banner/src/__tests__/blocker-bridge.test.ts
Normal file
116
apps/banner/src/__tests__/blocker-bridge.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the loader ↔ bundle blocker bridge.
|
||||||
|
*
|
||||||
|
* ``consent-loader.js`` and ``consent-bundle.js`` are compiled as
|
||||||
|
* separate rollup IIFEs, so each one inlines its own copy of
|
||||||
|
* ``blocker.ts`` with private module state. The bundle therefore
|
||||||
|
* can't reach the loader's ``acceptedCategories`` set via a direct
|
||||||
|
* import — it has to call through ``window.__consentos._updateBlocker``,
|
||||||
|
* which the loader sets after ``installBlocker()``.
|
||||||
|
*
|
||||||
|
* We mock the imports the banner module pulls in so importing
|
||||||
|
* ``banner.ts`` here doesn't try to hit real network / timers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Seed ``window.__consentos`` before banner.ts's init() IIFE runs at
|
||||||
|
// import time. Without this, destructuring ``window.__consentos`` at
|
||||||
|
// the top of init() throws and fills the test output with noise.
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as any).window = (globalThis as any).window || globalThis;
|
||||||
|
(globalThis as any).window.__consentos = {
|
||||||
|
siteId: '',
|
||||||
|
apiBase: '',
|
||||||
|
cdnBase: '',
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../consent', () => ({
|
||||||
|
buildConsentState: vi.fn(() => ({ accepted: [], rejected: [] })),
|
||||||
|
readConsent: vi.fn(() => null),
|
||||||
|
writeConsent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../gcm', () => ({
|
||||||
|
buildGcmStateFromCategories: vi.fn(() => ({})),
|
||||||
|
updateGcm: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../i18n', () => ({
|
||||||
|
DEFAULT_TRANSLATIONS: {},
|
||||||
|
detectLocale: vi.fn(() => 'en'),
|
||||||
|
interpolate: vi.fn((s: string) => s),
|
||||||
|
loadTranslations: vi.fn(async () => ({})),
|
||||||
|
renderLinks: vi.fn((s: string) => s),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../a11y', () => ({
|
||||||
|
announce: vi.fn(),
|
||||||
|
createLiveRegion: vi.fn(),
|
||||||
|
focusFirst: vi.fn(),
|
||||||
|
onEscape: vi.fn(() => () => {}),
|
||||||
|
prefersReducedMotion: vi.fn(() => false),
|
||||||
|
trapFocus: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Prevent banner.ts's init() IIFE from running against real globals.
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('mocked'))));
|
||||||
|
|
||||||
|
import { updateAcceptedCategories } from '../banner';
|
||||||
|
|
||||||
|
describe('loader ↔ bundle blocker bridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.__consentos = {
|
||||||
|
siteId: 'test',
|
||||||
|
apiBase: 'https://api.example.com',
|
||||||
|
cdnBase: 'https://cdn.example.com',
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls window.__consentos._updateBlocker when the bridge is present', () => {
|
||||||
|
const bridge = vi.fn();
|
||||||
|
window.__consentos._updateBlocker = bridge;
|
||||||
|
|
||||||
|
updateAcceptedCategories(['necessary', 'analytics']);
|
||||||
|
|
||||||
|
expect(bridge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bridge).toHaveBeenCalledWith(['necessary', 'analytics']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the exact array reference so the loader sees every slug', () => {
|
||||||
|
const bridge = vi.fn();
|
||||||
|
window.__consentos._updateBlocker = bridge;
|
||||||
|
|
||||||
|
const accepted = ['necessary', 'functional', 'marketing'] as const;
|
||||||
|
updateAcceptedCategories([...accepted]);
|
||||||
|
|
||||||
|
const args = bridge.mock.calls[0][0];
|
||||||
|
expect(args).toEqual(['necessary', 'functional', 'marketing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and returns cleanly when the bridge is missing', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
delete window.__consentos._updateBlocker;
|
||||||
|
|
||||||
|
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('blocker bridge missing'),
|
||||||
|
);
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when window.__consentos is missing entirely', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
// @ts-expect-error — simulating a pre-init state
|
||||||
|
window.__consentos = undefined;
|
||||||
|
|
||||||
|
expect(() => updateAcceptedCategories(['necessary'])).not.toThrow();
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isCategoryAllowed,
|
isCategoryAllowed,
|
||||||
addScriptPatterns,
|
addScriptPatterns,
|
||||||
loadInitiatorMappings,
|
loadInitiatorMappings,
|
||||||
|
sweepDisallowedState,
|
||||||
} from '../blocker';
|
} from '../blocker';
|
||||||
|
|
||||||
describe('blocker', () => {
|
describe('blocker', () => {
|
||||||
@@ -330,4 +331,109 @@ describe('blocker', () => {
|
|||||||
expect(isCategoryAllowed('necessary')).toBe(true);
|
expect(isCategoryAllowed('necessary')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sweepDisallowedState', () => {
|
||||||
|
/**
|
||||||
|
* Reset the cookie jar by expiring any test cookies we know
|
||||||
|
* about. ``document.cookie =`` runs through the proxy so
|
||||||
|
* ``_consentos_*`` passes the shortcut and everything else gets
|
||||||
|
* eaten by the blocker's classifier. Expiring the well-known
|
||||||
|
* analytics cookies here gives the individual tests a clean
|
||||||
|
* starting point without relying on the jsdom harness to wipe
|
||||||
|
* between tests.
|
||||||
|
*/
|
||||||
|
function resetCookies(names: string[]) {
|
||||||
|
for (const name of names) {
|
||||||
|
document.cookie = `_consentos_reset_marker=${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Directly seed cookies via the native descriptor so the
|
||||||
|
// proxied setter doesn't eat them as "tracker writes with
|
||||||
|
// no consent".
|
||||||
|
const nativeSet = Object.getOwnPropertyDescriptor(
|
||||||
|
Document.prototype,
|
||||||
|
'cookie',
|
||||||
|
)?.set;
|
||||||
|
if (!nativeSet) throw new Error('cannot locate native cookie setter');
|
||||||
|
nativeSet.call(
|
||||||
|
document,
|
||||||
|
'_ga=GA1.2.seed; path=/',
|
||||||
|
);
|
||||||
|
nativeSet.call(
|
||||||
|
document,
|
||||||
|
'_fbp=fb.2.seed; path=/',
|
||||||
|
);
|
||||||
|
nativeSet.call(
|
||||||
|
document,
|
||||||
|
'unknown_cookie=opaque; path=/',
|
||||||
|
);
|
||||||
|
nativeSet.call(
|
||||||
|
document,
|
||||||
|
'_consentos_consent=%7B%7D; path=/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetCookies(['_ga', '_fbp', 'unknown_cookie', '_consentos_consent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes non-consented analytics cookies', () => {
|
||||||
|
updateAcceptedCategories(['necessary']);
|
||||||
|
// ^^ updateAcceptedCategories calls sweep internally; the
|
||||||
|
// assertions below verify the post-sweep cookie jar.
|
||||||
|
expect(document.cookie).not.toContain('_ga=');
|
||||||
|
expect(document.cookie).not.toContain('_fbp=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves consented cookies alone', () => {
|
||||||
|
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
|
||||||
|
expect(document.cookie).toContain('_ga=');
|
||||||
|
expect(document.cookie).toContain('_fbp=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unknown cookies alone even without consent', () => {
|
||||||
|
updateAcceptedCategories(['necessary']);
|
||||||
|
expect(document.cookie).toContain('unknown_cookie=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches _consentos_* cookies', () => {
|
||||||
|
updateAcceptedCategories(['necessary']);
|
||||||
|
expect(document.cookie).toContain('_consentos_consent=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('standalone sweepDisallowedState respects the current set', () => {
|
||||||
|
updateAcceptedCategories(['necessary', 'analytics']);
|
||||||
|
// Re-seed _ga after the first sweep would have left it (analytics consented).
|
||||||
|
const nativeSet = Object.getOwnPropertyDescriptor(
|
||||||
|
Document.prototype,
|
||||||
|
'cookie',
|
||||||
|
)?.set;
|
||||||
|
nativeSet?.call(document, '_fbp=fb.2.reseed; path=/');
|
||||||
|
|
||||||
|
// Revoke marketing, sweep again.
|
||||||
|
updateAcceptedCategories(['necessary', 'analytics']);
|
||||||
|
sweepDisallowedState();
|
||||||
|
expect(document.cookie).toContain('_ga=');
|
||||||
|
expect(document.cookie).not.toContain('_fbp=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans non-consented localStorage keys', () => {
|
||||||
|
localStorage.setItem('_consentos_keep', 'yes');
|
||||||
|
// Seed via a direct setItem — the proxied setter would block
|
||||||
|
// non-necessary writes, but we want a pre-existing key.
|
||||||
|
const nativeSetItem = Object.getPrototypeOf(localStorage).setItem;
|
||||||
|
nativeSetItem.call(localStorage, '_ga_stuff', 'tracker');
|
||||||
|
nativeSetItem.call(localStorage, 'opaque_key', 'leave-alone');
|
||||||
|
|
||||||
|
updateAcceptedCategories(['necessary']);
|
||||||
|
|
||||||
|
expect(localStorage.getItem('_consentos_keep')).toBe('yes');
|
||||||
|
expect(localStorage.getItem('_ga_stuff')).toBeNull();
|
||||||
|
expect(localStorage.getItem('opaque_key')).toBe('leave-alone');
|
||||||
|
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,12 +10,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
|
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
|
||||||
import { updateAcceptedCategories } from './blocker';
|
// NB: intentionally NOT importing from './blocker'. The loader already
|
||||||
|
// installed the blocker proxies in its own IIFE module scope, and
|
||||||
|
// the bundle can't share that state via a direct import — rollup
|
||||||
|
// builds ``consent-loader.js`` and ``consent-bundle.js`` as separate
|
||||||
|
// IIFEs so each one inlines its own private copy of every module.
|
||||||
|
// The loader exposes ``_updateBlocker`` on ``window.__consentos``
|
||||||
|
// for us to drive its proxies — see ``updateAcceptedCategories``
|
||||||
|
// below and ``apps/banner/src/loader.ts``.
|
||||||
import { buildConsentState, readConsent, writeConsent } from './consent';
|
import { buildConsentState, readConsent, writeConsent } from './consent';
|
||||||
import { buildGcmStateFromCategories, updateGcm } from './gcm';
|
import { buildGcmStateFromCategories, updateGcm } from './gcm';
|
||||||
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
|
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
|
||||||
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
|
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the loader's blocker proxies with a new accepted-categories
|
||||||
|
* set. Falls back to a ``console.warn`` if the bridge is missing,
|
||||||
|
* which would mean the loader hasn't finished ``installBlocker`` yet
|
||||||
|
* (shouldn't happen — the bundle only loads after the loader's
|
||||||
|
* synchronous init phase). Exported for unit testing only.
|
||||||
|
*/
|
||||||
|
export function updateAcceptedCategories(accepted: CategorySlug[]): void {
|
||||||
|
const bridge = window.__consentos?._updateBlocker;
|
||||||
|
if (typeof bridge === 'function') {
|
||||||
|
bridge(accepted);
|
||||||
|
} else if (typeof console !== 'undefined') {
|
||||||
|
console.warn(
|
||||||
|
'[ConsentOS] blocker bridge missing — consent granted but ' +
|
||||||
|
'cookie/script blocker state was not updated. The loader ' +
|
||||||
|
'may not have initialised correctly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Preference-centre closure captured during init() ---------------------
|
// -- Preference-centre closure captured during init() ---------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +116,12 @@ export function registerEEHooks(hooks: Partial<EEHooks>): void {
|
|||||||
// Expose for EE bundle
|
// Expose for EE bundle
|
||||||
(window as any).__consentos_register_ee = registerEEHooks;
|
(window as any).__consentos_register_ee = registerEEHooks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every known category, in canonical display order. Used as the
|
||||||
|
* fallback when ``SiteConfig.enabled_categories`` isn't present in
|
||||||
|
* the API response (older deployments) and as the reference order
|
||||||
|
* for deduping / sorting runtime subsets.
|
||||||
|
*/
|
||||||
const ALL_CATEGORIES: CategorySlug[] = [
|
const ALL_CATEGORIES: CategorySlug[] = [
|
||||||
'necessary',
|
'necessary',
|
||||||
'functional',
|
'functional',
|
||||||
@@ -97,12 +130,33 @@ const ALL_CATEGORIES: CategorySlug[] = [
|
|||||||
'personalisation',
|
'personalisation',
|
||||||
];
|
];
|
||||||
|
|
||||||
const NON_ESSENTIAL: CategorySlug[] = [
|
/**
|
||||||
'functional',
|
* Return the categories the banner should render for this config.
|
||||||
'analytics',
|
* ``necessary`` is always implicit and forced back in if missing;
|
||||||
'marketing',
|
* unknown slugs are filtered; the result is sorted into the canonical
|
||||||
'personalisation',
|
* display order so toggle positions don't jump around based on the
|
||||||
];
|
* cascade's insertion order. When the field is absent we return the
|
||||||
|
* full five — matches legacy behaviour and keeps older banner
|
||||||
|
* bundles working against an older API.
|
||||||
|
*/
|
||||||
|
function resolveEnabledCategories(config: SiteConfig): CategorySlug[] {
|
||||||
|
const raw = config.enabled_categories;
|
||||||
|
if (!raw || !Array.isArray(raw) || raw.length === 0) {
|
||||||
|
return [...ALL_CATEGORIES];
|
||||||
|
}
|
||||||
|
const picked = new Set<CategorySlug>(
|
||||||
|
raw.filter((slug): slug is CategorySlug =>
|
||||||
|
(ALL_CATEGORIES as string[]).includes(slug as string),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
picked.add('necessary');
|
||||||
|
return ALL_CATEGORIES.filter((slug) => picked.has(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Categories the user can toggle — everything except ``necessary``. */
|
||||||
|
function nonEssentialFor(enabled: CategorySlug[]): CategorySlug[] {
|
||||||
|
return enabled.filter((slug) => slug !== 'necessary');
|
||||||
|
}
|
||||||
|
|
||||||
/** Initialise the banner. Called when the bundle loads. */
|
/** Initialise the banner. Called when the bundle loads. */
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
@@ -240,6 +294,7 @@ function buildDefaultConfig(siteId: string): SiteConfig {
|
|||||||
consent_group_id: null,
|
consent_group_id: null,
|
||||||
ab_test: null,
|
ab_test: null,
|
||||||
initiator_map: null,
|
initiator_map: null,
|
||||||
|
enabled_categories: [...ALL_CATEGORIES],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +321,9 @@ function renderBanner(
|
|||||||
const titleId = 'cmp-title';
|
const titleId = 'cmp-title';
|
||||||
const descId = 'cmp-desc';
|
const descId = 'cmp-desc';
|
||||||
|
|
||||||
|
const enabledCategories = resolveEnabledCategories(config);
|
||||||
|
const nonEssential = nonEssentialFor(enabledCategories);
|
||||||
|
|
||||||
shadow.innerHTML = `
|
shadow.innerHTML = `
|
||||||
<style>${getBannerStyles(config)}</style>
|
<style>${getBannerStyles(config)}</style>
|
||||||
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
|
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
|
||||||
@@ -277,7 +335,7 @@ function renderBanner(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
|
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
|
||||||
${renderCategories(t)}
|
${renderCategories(t, enabledCategories)}
|
||||||
</div>
|
</div>
|
||||||
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
|
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
|
||||||
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
|
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
|
||||||
@@ -321,7 +379,7 @@ function renderBanner(
|
|||||||
// Set up keyboard navigation
|
// Set up keyboard navigation
|
||||||
const cleanupFocusTrap = trapFocus(banner);
|
const cleanupFocusTrap = trapFocus(banner);
|
||||||
const cleanupEscape = onEscape(banner, () => {
|
const cleanupEscape = onEscape(banner, () => {
|
||||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,11 +387,12 @@ function renderBanner(
|
|||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
|
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
|
||||||
if (action === 'accept') {
|
if (action === 'accept') {
|
||||||
// Explicit Accept All overrides GPC — user choice takes precedence
|
// Explicit Accept All overrides GPC — user choice takes precedence.
|
||||||
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
|
// "All" only includes the categories the operator has enabled.
|
||||||
|
handleConsent([...enabledCategories], [], config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
} else if (action === 'reject') {
|
} else if (action === 'reject') {
|
||||||
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
|
handleConsent(['necessary'], nonEssential, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
} else if (action === 'settings') {
|
} else if (action === 'settings') {
|
||||||
const isHidden = categoriesDiv.style.display === 'none';
|
const isHidden = categoriesDiv.style.display === 'none';
|
||||||
@@ -342,7 +401,7 @@ function renderBanner(
|
|||||||
announce(liveRegion, isHidden ? t.managePreferences : t.title);
|
announce(liveRegion, isHidden ? t.managePreferences : t.title);
|
||||||
} else if (action === 'save') {
|
} else if (action === 'save') {
|
||||||
const accepted = getSelectedCategories(shadow);
|
const accepted = getSelectedCategories(shadow);
|
||||||
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
|
const rejected = nonEssential.filter((c) => !accepted.includes(c));
|
||||||
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
|
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
|
||||||
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
removeBanner(host, cleanupFocusTrap, cleanupEscape);
|
||||||
}
|
}
|
||||||
@@ -355,16 +414,20 @@ function renderBanner(
|
|||||||
focusFirst(banner);
|
focusFirst(banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render category toggles HTML. */
|
/** Render category toggles HTML. Only renders the categories the
|
||||||
function renderCategories(t: TranslationStrings): string {
|
* config has enabled — ``necessary`` is always present and locked. */
|
||||||
const categories = [
|
function renderCategories(t: TranslationStrings, enabled: CategorySlug[]): string {
|
||||||
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
|
const all = [
|
||||||
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
|
{ slug: 'necessary' as const, name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
|
||||||
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
|
{ slug: 'functional' as const, name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
|
||||||
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
|
{ slug: 'analytics' as const, name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
|
||||||
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
|
{ slug: 'marketing' as const, name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
|
||||||
|
{ slug: 'personalisation' as const, name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const enabledSet = new Set(enabled);
|
||||||
|
const categories = all.filter((cat) => enabledSet.has(cat.slug));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
categories
|
categories
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -122,12 +122,29 @@ export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the set of accepted categories and release any blocked scripts
|
* Update the set of accepted categories, release any blocked scripts
|
||||||
* that now have consent.
|
* that now have consent, and sweep any existing cookies / storage
|
||||||
|
* items that belong to a category the visitor has **not** consented
|
||||||
|
* to. Consented categories are left untouched — those cookies are
|
||||||
|
* presumed to be in use by the site.
|
||||||
*/
|
*/
|
||||||
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
||||||
acceptedCategories = new Set(categories);
|
acceptedCategories = new Set(categories);
|
||||||
releaseBlockedScripts();
|
releaseBlockedScripts();
|
||||||
|
sweepDisallowedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete any existing cookies and storage items whose classified
|
||||||
|
* category isn't currently consented. Runs on install and every
|
||||||
|
* consent-state update so historical trackers from pre-consent, a
|
||||||
|
* previous session, or a narrowed consent decision get removed.
|
||||||
|
* Unknown / unclassified cookies are left alone since we can't
|
||||||
|
* attribute them to a category.
|
||||||
|
*/
|
||||||
|
export function sweepDisallowedState(): void {
|
||||||
|
sweepDisallowedCookies();
|
||||||
|
sweepDisallowedStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the current blocked script count (useful for debugging/reporting). */
|
/** Get the current blocked script count (useful for debugging/reporting). */
|
||||||
@@ -492,6 +509,114 @@ function allNonEssentialConsented(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sweep existing state ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Delete classified cookies that aren't in a consented category. */
|
||||||
|
function sweepDisallowedCookies(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
if (!originalCookieDescriptor?.set) return;
|
||||||
|
|
||||||
|
const cookieHeader = document.cookie || '';
|
||||||
|
if (!cookieHeader) return;
|
||||||
|
|
||||||
|
const nativeSet = originalCookieDescriptor.set.bind(document);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of cookieHeader.split(';')) {
|
||||||
|
const name = parseCookieName(entry);
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
|
||||||
|
// Never touch ConsentOS's own cookies.
|
||||||
|
if (name.startsWith('_consentos_')) continue;
|
||||||
|
|
||||||
|
const category = classifyCookie(name);
|
||||||
|
// Unknown / unclassified cookies get left alone — we can't
|
||||||
|
// attribute them so we can't safely delete them.
|
||||||
|
if (!category || category === 'necessary') continue;
|
||||||
|
if (acceptedCategories.has(category)) continue;
|
||||||
|
|
||||||
|
// Expire the cookie. We don't know the domain / path the cookie
|
||||||
|
// was set on, so we fire deletes for every plausible combination:
|
||||||
|
// the current hostname bare, the leading-dot form, and every
|
||||||
|
// parent domain walked up from the left. This catches the common
|
||||||
|
// case of analytics cookies set on ``.example.com`` from a
|
||||||
|
// subdomain page without over-deleting.
|
||||||
|
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||||
|
try {
|
||||||
|
nativeSet(`${name}=; ${expired}`);
|
||||||
|
for (const domain of domainVariants()) {
|
||||||
|
nativeSet(`${name}=; ${expired}; domain=${domain}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Writing a cookie can throw in exotic sandboxed contexts;
|
||||||
|
// best-effort, don't crash the loader.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived list of plausible cookie domains for the current hostname. */
|
||||||
|
function domainVariants(): string[] {
|
||||||
|
if (typeof location === 'undefined' || !location.hostname) return [];
|
||||||
|
|
||||||
|
const hostname = location.hostname;
|
||||||
|
// IP addresses and ``localhost`` have no "parent domain" concept.
|
||||||
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname === 'localhost') {
|
||||||
|
return [hostname];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
const variants: string[] = [];
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const parent = parts.slice(i).join('.');
|
||||||
|
if (parent) {
|
||||||
|
variants.push(parent, `.${parent}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(new Set(variants));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete classified localStorage / sessionStorage keys that aren't consented. */
|
||||||
|
function sweepDisallowedStorage(): void {
|
||||||
|
if (typeof Storage === 'undefined') return;
|
||||||
|
|
||||||
|
for (const storage of [safeStorage('local'), safeStorage('session')]) {
|
||||||
|
if (!storage) continue;
|
||||||
|
|
||||||
|
const toRemove: string[] = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (!key || key.startsWith('_consentos_')) continue;
|
||||||
|
const category = classifyStorageKey(key);
|
||||||
|
if (!category || category === 'necessary') continue;
|
||||||
|
if (acceptedCategories.has(category)) continue;
|
||||||
|
toRemove.push(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of toRemove) {
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Ignore quota / security errors — best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the requested Storage instance, or null if inaccessible. */
|
||||||
|
function safeStorage(kind: 'local' | 'session'): Storage | null {
|
||||||
|
try {
|
||||||
|
return kind === 'local' ? window.localStorage : window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
// Access can throw on cross-origin / sandboxed iframes.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Teardown (for testing) ───
|
// ─── Teardown (for testing) ───
|
||||||
|
|
||||||
/** Remove all interception hooks. Used in tests. */
|
/** Remove all interception hooks. Used in tests. */
|
||||||
|
|||||||
@@ -8,11 +8,16 @@
|
|||||||
* 4. Fetch site config from CDN/API
|
* 4. Fetch site config from CDN/API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { installBlocker, updateAcceptedCategories } from './blocker';
|
import {
|
||||||
|
installBlocker,
|
||||||
|
sweepDisallowedState,
|
||||||
|
updateAcceptedCategories,
|
||||||
|
} from './blocker';
|
||||||
import { hasConsent, readConsent } from './consent';
|
import { hasConsent, readConsent } from './consent';
|
||||||
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
|
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
|
||||||
import { isGpcEnabled } from './gpc';
|
import { isGpcEnabled } from './gpc';
|
||||||
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
|
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
|
||||||
|
import type { CategorySlug } from './types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -25,6 +30,15 @@ declare global {
|
|||||||
visitorRegion?: string;
|
visitorRegion?: string;
|
||||||
/** Whether GPC signal was detected by the loader. */
|
/** Whether GPC signal was detected by the loader. */
|
||||||
gpcDetected?: boolean;
|
gpcDetected?: boolean;
|
||||||
|
/**
|
||||||
|
* Internal: drives the blocker installed by the loader. The
|
||||||
|
* banner bundle is a separate IIFE with its own module scope,
|
||||||
|
* so it can't share ``acceptedCategories`` via a direct import
|
||||||
|
* — it has to call back through this bridge. See
|
||||||
|
* ``apps/banner/src/blocker.ts`` for the state it mutates.
|
||||||
|
* Consumers outside the banner bundle should not call this.
|
||||||
|
*/
|
||||||
|
_updateBlocker?: (accepted: CategorySlug[]) => void;
|
||||||
};
|
};
|
||||||
/** Public ConsentOS API for site integration. */
|
/** Public ConsentOS API for site integration. */
|
||||||
ConsentOS: {
|
ConsentOS: {
|
||||||
@@ -101,6 +115,16 @@ declare global {
|
|||||||
// 1. Install script/cookie blocker immediately (before any third-party scripts)
|
// 1. Install script/cookie blocker immediately (before any third-party scripts)
|
||||||
installBlocker();
|
installBlocker();
|
||||||
|
|
||||||
|
// 1a. Bridge the blocker to the full banner bundle. ``consent-bundle.js``
|
||||||
|
// is built as a separate rollup IIFE with its own module scope, so it
|
||||||
|
// gets its own dead-end copy of ``blocker.ts``. Expose the loader's
|
||||||
|
// live ``updateAcceptedCategories`` on ``window.__consentos`` so
|
||||||
|
// ``handleConsent`` in the bundle can drive the loader's proxies
|
||||||
|
// directly. Without this, consent updates from the bundle would only
|
||||||
|
// mutate the bundle's copy and the cookie/storage proxies running in
|
||||||
|
// the loader's scope would stay stuck on ``Set(['necessary'])``.
|
||||||
|
window.__consentos._updateBlocker = updateAcceptedCategories;
|
||||||
|
|
||||||
// 1b. Install __gpp stub — queues calls until the full bundle loads
|
// 1b. Install __gpp stub — queues calls until the full bundle loads
|
||||||
installGppStub();
|
installGppStub();
|
||||||
|
|
||||||
@@ -114,7 +138,11 @@ declare global {
|
|||||||
const existingConsent = readConsent();
|
const existingConsent = readConsent();
|
||||||
|
|
||||||
if (existingConsent) {
|
if (existingConsent) {
|
||||||
// Consent already given — update blocker, GCM, and we're done
|
// Consent already given — update blocker (which also sweeps any
|
||||||
|
// cookies / storage in non-accepted categories), update GCM, and
|
||||||
|
// we're done. ``updateAcceptedCategories`` runs the sweep
|
||||||
|
// internally so historical trackers from a previously-wider
|
||||||
|
// consent set get cleaned up.
|
||||||
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
|
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
|
||||||
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
|
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
|
||||||
updateGcm(gcmState);
|
updateGcm(gcmState);
|
||||||
@@ -124,7 +152,15 @@ declare global {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. No consent — async-load the full banner bundle
|
// 4. No consent. Sweep any pre-existing classified trackers
|
||||||
|
// (typically ``_ga``, ``_fbp`` and friends that slipped in before
|
||||||
|
// the blocker was installed — e.g. from a script-ordering bug on
|
||||||
|
// the host page) so the visitor starts from a clean slate. Runs
|
||||||
|
// against the default ``Set(['necessary'])`` so every non-necessary
|
||||||
|
// known tracker is deleted.
|
||||||
|
sweepDisallowedState();
|
||||||
|
|
||||||
|
// 5. Async-load the full banner bundle
|
||||||
loadBannerBundle(cdnBase);
|
loadBannerBundle(cdnBase);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export interface SiteConfig {
|
|||||||
ab_test: ABTestConfig | null;
|
ab_test: ABTestConfig | null;
|
||||||
/** Initiator map: root script URL → category for root-level blocking. */
|
/** Initiator map: root script URL → category for root-level blocking. */
|
||||||
initiator_map: InitiatorMapping[] | null;
|
initiator_map: InitiatorMapping[] | null;
|
||||||
|
/**
|
||||||
|
* Cookie categories the banner should render. Always contains
|
||||||
|
* ``necessary``; operators subset the remaining four via the config
|
||||||
|
* cascade (site → group → org → system default of all five). Older
|
||||||
|
* API responses may omit this field — callers should fall back to
|
||||||
|
* every known category in that case.
|
||||||
|
*/
|
||||||
|
enabled_categories?: CategorySlug[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps a root initiator script to the cookie category it ultimately sets. */
|
/** Maps a root initiator script to the cookie category it ultimately sets. */
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
"""Playwright-based headless browser cookie crawler.
|
"""Playwright-based headless browser cookie crawler.
|
||||||
|
|
||||||
For each URL: launches headless Chromium, clears cookies, navigates,
|
For each URL: launches headless Chromium, **pre-seeds an
|
||||||
waits for network idle, enumerates document.cookie / localStorage /
|
"all categories accepted" ConsentOS consent cookie**, clears any other
|
||||||
sessionStorage, captures Set-Cookie headers from network requests,
|
cookies, navigates, waits for network idle, enumerates
|
||||||
and attributes cookies to source scripts via the request chain.
|
``document.cookie`` / ``localStorage`` / ``sessionStorage``, captures
|
||||||
|
``Set-Cookie`` headers from network requests, and attributes cookies
|
||||||
|
to source scripts via the request chain.
|
||||||
|
|
||||||
|
The pre-seed is what makes the scan useful: without it the loader
|
||||||
|
would block analytics/marketing scripts and the scan would only see
|
||||||
|
strictly-necessary cookies, which tells you nothing about what the
|
||||||
|
site actually loads in the post-consent state. Pre-consent compliance
|
||||||
|
checks live in ``consent_validator.py`` and use a separate code path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from urllib.parse import urlparse
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from playwright.async_api import (
|
from playwright.async_api import (
|
||||||
BrowserContext,
|
BrowserContext,
|
||||||
@@ -22,6 +34,52 @@ from playwright.async_api import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# All ConsentOS categories — pre-seeded as accepted on every crawl so
|
||||||
|
# the loader's "consent already given" branch fires and unblocks all
|
||||||
|
# scripts/cookies.
|
||||||
|
_ALL_CATEGORIES: list[str] = [
|
||||||
|
"necessary",
|
||||||
|
"functional",
|
||||||
|
"analytics",
|
||||||
|
"marketing",
|
||||||
|
"personalisation",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Must match ``COOKIE_NAME`` in apps/banner/src/consent.ts. If you
|
||||||
|
# rename it there, rename it here too.
|
||||||
|
_CONSENT_COOKIE_NAME = "_consentos_consent"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_consent_cookie(url: str) -> dict:
|
||||||
|
"""Return a Playwright cookie dict pre-seeding ConsentOS consent.
|
||||||
|
|
||||||
|
Mirrors the shape that ``apps/banner/src/consent.ts:writeConsent``
|
||||||
|
produces — URL-encoded JSON of a ``ConsentState`` — so the loader's
|
||||||
|
``readConsent`` returns a valid object and short-circuits straight
|
||||||
|
to ``updateAcceptedCategories(...)``. Categories are hard-coded to
|
||||||
|
every known ConsentOS category; the scanner is a "what does this
|
||||||
|
site load when the visitor accepts everything?" tool, by design.
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"visitorId": str(uuid.uuid4()),
|
||||||
|
"accepted": _ALL_CATEGORIES,
|
||||||
|
"rejected": [],
|
||||||
|
"consentedAt": datetime.now(UTC).isoformat(),
|
||||||
|
"bannerVersion": "scanner",
|
||||||
|
}
|
||||||
|
value = quote(json.dumps(state, separators=(",", ":")), safe="")
|
||||||
|
# Playwright's ``add_cookies`` accepts EITHER ``url`` (from which
|
||||||
|
# it derives domain/path/secure) OR explicit ``domain`` + ``path``
|
||||||
|
# — but not both. Using ``url`` is simplest.
|
||||||
|
return {
|
||||||
|
"name": _CONSENT_COOKIE_NAME,
|
||||||
|
"value": value,
|
||||||
|
"url": url,
|
||||||
|
"expires": time.time() + 365 * 86400,
|
||||||
|
"sameSite": "Lax",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Realistic Chrome UA so sites don't block the crawler as a bot.
|
# Realistic Chrome UA so sites don't block the crawler as a bot.
|
||||||
_DEFAULT_USER_AGENT = (
|
_DEFAULT_USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
@@ -145,6 +203,9 @@ class CookieCrawler:
|
|||||||
script_cookies: dict[str, str] = {} # cookie name → script URL
|
script_cookies: dict[str, str] = {} # cookie name → script URL
|
||||||
initiator_map: dict[str, str] = {} # request URL → initiating URL
|
initiator_map: dict[str, str] = {} # request URL → initiating URL
|
||||||
initiator_chains: dict[str, list[str]] = {} # cookie name → chain
|
initiator_chains: dict[str, list[str]] = {} # cookie name → chain
|
||||||
|
# Cookies discovered directly from Set-Cookie response headers.
|
||||||
|
# Keyed by (name, domain) so they can be merged with CDP results.
|
||||||
|
header_cookies: dict[tuple[str, str], DiscoveredCookie] = {}
|
||||||
|
|
||||||
context: BrowserContext | None = None
|
context: BrowserContext | None = None
|
||||||
try:
|
try:
|
||||||
@@ -152,8 +213,13 @@ class CookieCrawler:
|
|||||||
user_agent=self._user_agent,
|
user_agent=self._user_agent,
|
||||||
ignore_https_errors=True,
|
ignore_https_errors=True,
|
||||||
)
|
)
|
||||||
# Clear all cookies before visiting
|
# Start from a clean slate, then plant the ConsentOS consent
|
||||||
|
# cookie so the loader treats the visitor as having already
|
||||||
|
# accepted every category. Without this the scan only sees
|
||||||
|
# strictly-necessary cookies — useless for "what does this
|
||||||
|
# site actually load?" reporting.
|
||||||
await context.clear_cookies()
|
await context.clear_cookies()
|
||||||
|
await context.add_cookies([_build_consent_cookie(url)])
|
||||||
|
|
||||||
page: Page = await context.new_page()
|
page: Page = await context.new_page()
|
||||||
|
|
||||||
@@ -175,7 +241,9 @@ class CookieCrawler:
|
|||||||
|
|
||||||
page.on("request", _on_request)
|
page.on("request", _on_request)
|
||||||
|
|
||||||
# Track Set-Cookie headers from responses
|
# Track Set-Cookie headers from responses and create
|
||||||
|
# DiscoveredCookie entries directly — CDP's context.cookies()
|
||||||
|
# may not enumerate cross-domain cookies.
|
||||||
async def _on_response(response: Response) -> None:
|
async def _on_response(response: Response) -> None:
|
||||||
try:
|
try:
|
||||||
headers = await response.all_headers()
|
headers = await response.all_headers()
|
||||||
@@ -186,25 +254,67 @@ class CookieCrawler:
|
|||||||
initiator = _get_script_initiator(request)
|
initiator = _get_script_initiator(request)
|
||||||
# Build the initiator chain for this request
|
# Build the initiator chain for this request
|
||||||
chain = _build_initiator_chain(request.url, initiator_map)
|
chain = _build_initiator_chain(request.url, initiator_map)
|
||||||
|
resp_domain = urlparse(response.url).hostname or ""
|
||||||
for cookie_str in set_cookie.split("\n"):
|
for cookie_str in set_cookie.split("\n"):
|
||||||
name = cookie_str.split("=")[0].strip()
|
name = cookie_str.split("=")[0].strip()
|
||||||
if name:
|
if name:
|
||||||
if initiator:
|
if initiator:
|
||||||
script_cookies[name] = initiator
|
script_cookies[name] = initiator
|
||||||
initiator_chains[name] = chain
|
initiator_chains[name] = chain
|
||||||
|
# Parse optional Domain attribute from
|
||||||
|
# the Set-Cookie header; fall back to
|
||||||
|
# the response hostname.
|
||||||
|
domain = resp_domain
|
||||||
|
for part in cookie_str.split(";")[1:]:
|
||||||
|
part = part.strip()
|
||||||
|
if part.lower().startswith("domain="):
|
||||||
|
domain = part.split("=", 1)[1].strip()
|
||||||
|
break
|
||||||
|
key = (name, domain)
|
||||||
|
if key not in header_cookies:
|
||||||
|
header_cookies[key] = DiscoveredCookie(
|
||||||
|
name=name,
|
||||||
|
domain=domain,
|
||||||
|
storage_type="cookie",
|
||||||
|
script_source=initiator,
|
||||||
|
page_url=url,
|
||||||
|
initiator_chain=chain,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Non-critical — response may have been aborted
|
pass # Non-critical — response may have been aborted
|
||||||
|
|
||||||
page.on("response", _on_response)
|
page.on("response", _on_response)
|
||||||
|
|
||||||
# Navigate
|
# Navigate — networkidle waits until ≤2 active connections for
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=self._timeout_ms)
|
# 500ms, which catches the GA beacon round-trip that
|
||||||
# Allow additional time for scripts to set cookies after DOM load.
|
# domcontentloaded misses.
|
||||||
await page.wait_for_timeout(3000)
|
await page.goto(url, wait_until="networkidle", timeout=self._timeout_ms)
|
||||||
|
# Safety margin for late-firing scripts (e.g. deferred GTM tags).
|
||||||
|
await page.wait_for_timeout(5000)
|
||||||
|
|
||||||
# Enumerate browser cookies via CDP
|
# First pass — enumerate browser cookies via CDP.
|
||||||
cdp_cookies = await context.cookies()
|
cdp_cookies = await context.cookies()
|
||||||
|
|
||||||
|
# Second pass — wait a further 2 seconds for any delayed
|
||||||
|
# Set-Cookie headers, then merge newly appeared cookies.
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
delayed_cookies = await context.cookies()
|
||||||
|
|
||||||
|
# Merge: index first-pass cookies by (name, domain), then
|
||||||
|
# add any that only appeared in the second pass.
|
||||||
|
seen_keys: set[tuple[str, str]] = set()
|
||||||
|
all_cdp_cookies: list[dict] = []
|
||||||
for c in cdp_cookies:
|
for c in cdp_cookies:
|
||||||
|
key = (c["name"], c["domain"])
|
||||||
|
seen_keys.add(key)
|
||||||
|
all_cdp_cookies.append(c)
|
||||||
|
for c in delayed_cookies:
|
||||||
|
key = (c["name"], c["domain"])
|
||||||
|
if key not in seen_keys:
|
||||||
|
seen_keys.add(key)
|
||||||
|
all_cdp_cookies.append(c)
|
||||||
|
|
||||||
|
for c in all_cdp_cookies:
|
||||||
result.cookies.append(
|
result.cookies.append(
|
||||||
DiscoveredCookie(
|
DiscoveredCookie(
|
||||||
name=c["name"],
|
name=c["name"],
|
||||||
@@ -222,6 +332,13 @@ class CookieCrawler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge cookies seen in Set-Cookie headers but NOT in the
|
||||||
|
# CDP cookie jar (e.g. cross-domain cookies that the browser
|
||||||
|
# scoped to a different origin).
|
||||||
|
for key, hc in header_cookies.items():
|
||||||
|
if key not in seen_keys:
|
||||||
|
result.cookies.append(hc)
|
||||||
|
|
||||||
# Enumerate localStorage
|
# Enumerate localStorage
|
||||||
ls_items = await page.evaluate("""() => {
|
ls_items = await page.evaluate("""() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ async def _fetch_sitemap(
|
|||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# SPAs with catch-all nginx/Caddy rules return 200 + text/html
|
||||||
|
# for /sitemap.xml. Don't try to parse HTML as XML.
|
||||||
|
content_type = resp.headers.get("content-type", "")
|
||||||
|
if "html" in content_type and "xml" not in content_type:
|
||||||
|
logger.debug("Sitemap %s returned HTML, skipping", url)
|
||||||
|
return []
|
||||||
|
|
||||||
root = ElementTree.fromstring(resp.text)
|
root = ElementTree.fromstring(resp.text)
|
||||||
|
|
||||||
# Check if it's a sitemap index
|
# Check if it's a sitemap index
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.crawler import (
|
from src.crawler import (
|
||||||
|
_ALL_CATEGORIES,
|
||||||
|
_CONSENT_COOKIE_NAME,
|
||||||
CookieCrawler,
|
CookieCrawler,
|
||||||
CrawlResult,
|
CrawlResult,
|
||||||
DiscoveredCookie,
|
DiscoveredCookie,
|
||||||
SiteCrawlResult,
|
SiteCrawlResult,
|
||||||
|
_build_consent_cookie,
|
||||||
_build_initiator_chain,
|
_build_initiator_chain,
|
||||||
_get_script_initiator,
|
_get_script_initiator,
|
||||||
)
|
)
|
||||||
@@ -39,11 +42,35 @@ def _make_mock_page(
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_context(page, cookies: list[dict] | None = None):
|
def _make_mock_context(
|
||||||
"""Build a mock BrowserContext."""
|
page,
|
||||||
|
cookies: list[dict] | None = None,
|
||||||
|
delayed_cookies: list[dict] | None = None,
|
||||||
|
):
|
||||||
|
"""Build a mock BrowserContext.
|
||||||
|
|
||||||
|
*cookies* is returned on the first ``context.cookies()`` call (the
|
||||||
|
initial CDP enumeration). *delayed_cookies* is returned on the
|
||||||
|
second call (the delayed pass); defaults to the same list so
|
||||||
|
existing tests need no changes.
|
||||||
|
"""
|
||||||
context = AsyncMock()
|
context = AsyncMock()
|
||||||
context.new_page = AsyncMock(return_value=page)
|
context.new_page = AsyncMock(return_value=page)
|
||||||
context.cookies = AsyncMock(return_value=cookies or [])
|
first = cookies or []
|
||||||
|
second = delayed_cookies if delayed_cookies is not None else first
|
||||||
|
# The crawler calls context.cookies() twice per page (initial +
|
||||||
|
# delayed pass). Using a cycling function instead of a fixed-length
|
||||||
|
# side_effect list so multi-page tests don't exhaust the mock.
|
||||||
|
_cycle = [first, second]
|
||||||
|
_call_count = 0
|
||||||
|
|
||||||
|
async def _cycling_cookies(*_args, **_kwargs):
|
||||||
|
nonlocal _call_count
|
||||||
|
result = _cycle[_call_count % len(_cycle)]
|
||||||
|
_call_count += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
context.cookies = AsyncMock(side_effect=_cycling_cookies)
|
||||||
context.clear_cookies = AsyncMock()
|
context.clear_cookies = AsyncMock()
|
||||||
context.close = AsyncMock()
|
context.close = AsyncMock()
|
||||||
return context
|
return context
|
||||||
@@ -370,6 +397,44 @@ class TestCrawlPage:
|
|||||||
call_kwargs = browser.new_context.call_args[1]
|
call_kwargs = browser.new_context.call_args[1]
|
||||||
assert call_kwargs["user_agent"] == "CMPBot/1.0"
|
assert call_kwargs["user_agent"] == "CMPBot/1.0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_two_pass_cookie_collection_merges_delayed(self):
|
||||||
|
"""Cookies appearing only in the second CDP pass are still discovered."""
|
||||||
|
first_pass = [
|
||||||
|
{"name": "_ga", "domain": ".example.com", "value": "GA1.2.12345"},
|
||||||
|
]
|
||||||
|
second_pass = [
|
||||||
|
{"name": "_ga", "domain": ".example.com", "value": "GA1.2.12345"},
|
||||||
|
{"name": "_gid", "domain": ".example.com", "value": "GID.99"},
|
||||||
|
]
|
||||||
|
|
||||||
|
page = _make_mock_page()
|
||||||
|
context = _make_mock_context(page, cookies=first_pass, delayed_cookies=second_pass)
|
||||||
|
browser = _make_mock_browser(context)
|
||||||
|
|
||||||
|
crawler = CookieCrawler()
|
||||||
|
result = await crawler._crawl_page(browser, "https://example.com/")
|
||||||
|
|
||||||
|
cookie_names = [c.name for c in result.cookies if c.storage_type == "cookie"]
|
||||||
|
assert "_ga" in cookie_names
|
||||||
|
assert "_gid" in cookie_names
|
||||||
|
# _ga must not be duplicated
|
||||||
|
assert cookie_names.count("_ga") == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_uses_networkidle_wait(self):
|
||||||
|
"""page.goto must use wait_until='networkidle'."""
|
||||||
|
page = _make_mock_page()
|
||||||
|
context = _make_mock_context(page)
|
||||||
|
browser = _make_mock_browser(context)
|
||||||
|
|
||||||
|
crawler = CookieCrawler()
|
||||||
|
await crawler._crawl_page(browser, "https://example.com/")
|
||||||
|
|
||||||
|
page.goto.assert_awaited_once()
|
||||||
|
call_kwargs = page.goto.call_args[1]
|
||||||
|
assert call_kwargs.get("wait_until") == "networkidle"
|
||||||
|
|
||||||
|
|
||||||
# ── CookieCrawler.crawl_site ───────────────────────────────────────────
|
# ── CookieCrawler.crawl_site ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -438,3 +503,87 @@ class TestCrawlSite:
|
|||||||
await crawler.crawl_site(["https://example.com/"])
|
await crawler.crawl_site(["https://example.com/"])
|
||||||
|
|
||||||
browser.close.assert_awaited_once()
|
browser.close.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Consent pre-seed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConsentCookie:
|
||||||
|
"""The pre-seeded ``_consentos_consent`` cookie."""
|
||||||
|
|
||||||
|
def test_cookie_name_matches_loader(self):
|
||||||
|
cookie = _build_consent_cookie("https://example.com/")
|
||||||
|
assert cookie["name"] == _CONSENT_COOKIE_NAME == "_consentos_consent"
|
||||||
|
|
||||||
|
def test_cookie_is_url_scoped_for_playwright(self):
|
||||||
|
"""``url`` lets Playwright derive domain / path / secure."""
|
||||||
|
cookie = _build_consent_cookie("https://example.com/page")
|
||||||
|
assert cookie["url"] == "https://example.com/page"
|
||||||
|
# ``path`` is NOT set explicitly — Playwright derives it from ``url``.
|
||||||
|
# Setting both would cause ``add_cookies`` to reject the cookie.
|
||||||
|
assert "path" not in cookie
|
||||||
|
|
||||||
|
def test_cookie_value_decodes_to_consent_state_with_all_categories(self):
|
||||||
|
import json as _json
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
cookie = _build_consent_cookie("https://example.com/")
|
||||||
|
state = _json.loads(unquote(cookie["value"]))
|
||||||
|
|
||||||
|
assert sorted(state["accepted"]) == sorted(_ALL_CATEGORIES)
|
||||||
|
assert state["rejected"] == []
|
||||||
|
# ConsentState fields the loader's readConsent() relies on
|
||||||
|
assert "visitorId" in state
|
||||||
|
assert "consentedAt" in state
|
||||||
|
assert "bannerVersion" in state
|
||||||
|
|
||||||
|
def test_cookie_expires_far_in_future(self):
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
cookie = _build_consent_cookie("https://example.com/")
|
||||||
|
# ~1 year, allow generous slack for test timing
|
||||||
|
assert cookie["expires"] > _time.time() + 300 * 86400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
@patch("src.crawler.async_playwright")
|
||||||
|
async def test_crawl_seeds_consent_before_navigation(self, mock_pw):
|
||||||
|
"""``add_cookies`` must be called before ``page.goto``."""
|
||||||
|
page = _make_mock_page()
|
||||||
|
context = _make_mock_context(page)
|
||||||
|
browser = _make_mock_browser(context)
|
||||||
|
|
||||||
|
# Track call order on the context
|
||||||
|
call_order: list[str] = []
|
||||||
|
original_add = context.add_cookies
|
||||||
|
original_clear = context.clear_cookies
|
||||||
|
|
||||||
|
async def _add(*args, **kwargs):
|
||||||
|
call_order.append("add_cookies")
|
||||||
|
return await original_add(*args, **kwargs)
|
||||||
|
|
||||||
|
async def _clear(*args, **kwargs):
|
||||||
|
call_order.append("clear_cookies")
|
||||||
|
return await original_clear(*args, **kwargs)
|
||||||
|
|
||||||
|
async def _goto(*args, **kwargs):
|
||||||
|
call_order.append("goto")
|
||||||
|
|
||||||
|
context.add_cookies = AsyncMock(side_effect=_add)
|
||||||
|
context.clear_cookies = AsyncMock(side_effect=_clear)
|
||||||
|
page.goto = AsyncMock(side_effect=_goto)
|
||||||
|
|
||||||
|
pw_instance = AsyncMock()
|
||||||
|
pw_instance.chromium.launch = AsyncMock(return_value=browser)
|
||||||
|
mock_pw.return_value.__aenter__ = AsyncMock(return_value=pw_instance)
|
||||||
|
mock_pw.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
crawler = CookieCrawler()
|
||||||
|
await crawler.crawl_site(["https://example.com/"])
|
||||||
|
|
||||||
|
assert call_order == ["clear_cookies", "add_cookies", "goto"], call_order
|
||||||
|
|
||||||
|
# And the cookie payload was the one we expect
|
||||||
|
seeded = context.add_cookies.call_args.args[0]
|
||||||
|
assert len(seeded) == 1
|
||||||
|
assert seeded[0]["name"] == "_consentos_consent"
|
||||||
|
assert seeded[0]["url"] == "https://example.com/"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "sh"
|
- "sh"
|
||||||
- "-c"
|
- "-c"
|
||||||
- "python -m alembic upgrade head && python -m src.cli.bootstrap_admin"
|
- "python -m alembic upgrade head && python -m src.cli.bootstrap_admin && python -m src.cli.seed_known_cookies"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
723
docs/deployment-guide.md
Normal file
723
docs/deployment-guide.md
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
# ConsentOS Deployment Guide
|
||||||
|
|
||||||
|
This guide covers deploying ConsentOS in production across three environments:
|
||||||
|
|
||||||
|
1. [Docker Compose](#1-docker-compose) — single VM, the quickest path to production
|
||||||
|
2. [Kubernetes (Helm)](#2-kubernetes-helm) — multi-node, auto-scaling, the long-term path
|
||||||
|
3. [Cloud Run / Serverless](#3-cloud-run--serverless) — managed containers, minimal ops
|
||||||
|
|
||||||
|
All three share the same container images, environment variables, and bootstrap flow. Pick whichever matches your infrastructure; mix and match where it makes sense (e.g. Cloud SQL for the database, Cloud Run for the API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before you begin, you'll need:
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| **Domain name** | Two DNS records: one for the admin UI + banner CDN (e.g. `cmp.example.com`), one for each customer site that embeds the banner (their own domains). |
|
||||||
|
| **TLS certificates** | Terminate TLS at your reverse proxy / load balancer (Caddy, nginx, Cloud Load Balancer). The containers serve plain HTTP internally. |
|
||||||
|
| **PostgreSQL 16+** | Built-in via Docker / Helm, or managed (RDS, Cloud SQL, Supabase). |
|
||||||
|
| **Redis 7+** | Built-in or managed (ElastiCache, Memorystore, Upstash). |
|
||||||
|
| **Docker or container runtime** | Docker Engine 24+ with Compose v2, or a Kubernetes cluster with Helm 3. |
|
||||||
|
| **Git** | To clone the repository. |
|
||||||
|
|
||||||
|
### Generating secrets
|
||||||
|
|
||||||
|
Several environment variables require strong random values. Generate them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JWT secret — used to sign access and refresh tokens
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Postgres password
|
||||||
|
openssl rand -hex 24
|
||||||
|
|
||||||
|
# Redis password
|
||||||
|
openssl rand -hex 24
|
||||||
|
|
||||||
|
# Admin bootstrap token (optional — gates runtime org creation)
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
All ConsentOS services read configuration from environment variables (or a `.env` file in Docker Compose). The canonical list with defaults is in `.env.example` at the repository root. The critical ones for production are:
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `ENVIRONMENT` | Yes | `development` | Set to `production`. The API refuses to start with unsafe defaults (placeholder JWT secret, wildcard CORS) when this is not `development`/`dev`/`test`. |
|
||||||
|
| `LOG_LEVEL` | No | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`. |
|
||||||
|
|
||||||
|
### Database & Redis
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | `postgresql+asyncpg://consentos:consentos@postgres:5432/consentos` | Async SQLAlchemy connection string. |
|
||||||
|
| `POSTGRES_USER` | Docker only | — | Used by the Postgres container to initialise the database. |
|
||||||
|
| `POSTGRES_PASSWORD` | Docker only | — | See above. |
|
||||||
|
| `POSTGRES_DB` | Docker only | — | See above. |
|
||||||
|
| `REDIS_URL` | Yes | `redis://localhost:6379/0` | Include the password as `redis://default:<password>@host:6379/0` if auth is enabled. |
|
||||||
|
| `REDIS_PASSWORD` | Docker only | — | Passed to the Redis container's `--requirepass`. |
|
||||||
|
|
||||||
|
### Authentication & Security
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `JWT_SECRET_KEY` | Yes | `CHANGE-ME-in-production` | Must be replaced. The API refuses to start in production with the placeholder value. Generate with `openssl rand -hex 32`. |
|
||||||
|
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | No | `30` | Access token lifetime. |
|
||||||
|
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | No | `7` | Refresh token lifetime. |
|
||||||
|
| `ALLOWED_ORIGINS` | Yes | `http://localhost:5173` | Comma-separated list of origins allowed to call the API. Include the admin UI origin and every customer site that embeds the banner. Wildcards are refused when `ENVIRONMENT` is not dev/test. |
|
||||||
|
|
||||||
|
### Initial Admin Bootstrap
|
||||||
|
|
||||||
|
On first startup, if the `users` table is empty and both credentials below are set, the bootstrap init container creates an organisation and an owner user so you can log in to the admin UI. Idempotent — once any user exists, this is a no-op.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `INITIAL_ADMIN_EMAIL` | Recommended | — | E-mail address for the first admin user. |
|
||||||
|
| `INITIAL_ADMIN_PASSWORD` | Recommended | — | Password for the first admin user. **Rotate via the admin UI after first login.** |
|
||||||
|
| `INITIAL_ADMIN_FULL_NAME` | No | `Administrator` | Display name. |
|
||||||
|
| `INITIAL_ORG_NAME` | No | `Default Organisation` | Name of the initial organisation. |
|
||||||
|
| `INITIAL_ORG_SLUG` | No | `default` | URL slug for the initial organisation. |
|
||||||
|
|
||||||
|
### CDN & Banner
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `CDN_BASE_URL` | Yes | `http://localhost:5173` | Public URL where `consent-loader.js` and `consent-bundle.js` are hosted. In the default Docker Compose deployment, this is the same origin as the admin UI (the admin-ui image bundles the banner at its nginx root). |
|
||||||
|
|
||||||
|
### GeoIP
|
||||||
|
|
||||||
|
ConsentOS resolves visitor location for regional consent modes (e.g. opt-in for EU, opt-out for California). Resolution runs in order: CDN headers → local MaxMind database → external API fallback.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `GEOIP_COUNTRY_HEADER` | No | — | Custom HTTP header carrying the visitor's ISO 3166-1 alpha-2 country code. Checked before the built-in list (`cf-ipcountry`, `x-vercel-ip-country`, `x-appengine-country`, `x-country-code`). Case-insensitive. |
|
||||||
|
| `GEOIP_REGION_HEADER` | No | — | Companion header carrying the ISO 3166-2 subdivision code (e.g. `CA` for California, `SCT` for Scotland). Paired with `GEOIP_COUNTRY_HEADER` to produce region keys like `US-CA` or `GB-SCT`. |
|
||||||
|
| `GEOIP_MAXMIND_DB_PATH` | No | — | Path to a local MaxMind GeoLite2-City `.mmdb` file. Used when no CDN header resolves. Download from [MaxMind](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) (free, registration required). |
|
||||||
|
|
||||||
|
**Common CDN header configurations:**
|
||||||
|
|
||||||
|
| CDN / Load Balancer | `GEOIP_COUNTRY_HEADER` | `GEOIP_REGION_HEADER` |
|
||||||
|
|---------------------|------------------------|-----------------------|
|
||||||
|
| Cloudflare (all plans) | `cf-ipcountry` *(built-in, no env needed)* | — |
|
||||||
|
| Cloudflare (Enterprise) | `cf-ipcountry` *(built-in)* | `cf-region-code` |
|
||||||
|
| Vercel | `x-vercel-ip-country` *(built-in)* | `x-vercel-ip-country-region` |
|
||||||
|
| Google Cloud Load Balancer | `x-gclb-country` | `x-gclb-region` |
|
||||||
|
| AWS CloudFront (functions) | `cloudfront-viewer-country` | `cloudfront-viewer-country-region` |
|
||||||
|
| Generic / custom | *your header name* | *your header name* |
|
||||||
|
|
||||||
|
> **Cloudflare users**: `cf-ipcountry` is in the built-in list, so you don't need to set `GEOIP_COUNTRY_HEADER` at all. Country-level resolution works out of the box. For US-state or UK-region granularity, set `GEOIP_REGION_HEADER=cf-region-code` (requires a Cloudflare Enterprise plan or a Managed Transform rule that exposes the header).
|
||||||
|
|
||||||
|
### Scanner
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `SCANNER_SERVICE_URL` | Yes (API) | `http://localhost:8001` | URL the Celery worker uses to reach the scanner service. In Docker Compose this is `http://consentos-scanner:8001`. |
|
||||||
|
|
||||||
|
> **Important**: the scanner must NOT share the API's `.env` file via `env_file:`. Variables like `PORT` leak across and rebind the scanner off its default `8001`. Use an explicit `environment:` block instead (the prod compose already does this).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Docker Compose
|
||||||
|
|
||||||
|
The fastest path to a running ConsentOS instance. One VM, one `docker compose up`, everything behind a reverse proxy like Caddy or nginx.
|
||||||
|
|
||||||
|
### 1.1 Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ConsentOS/consentos.git /opt/consentos
|
||||||
|
cd /opt/consentos
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Create the `.env` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set at minimum:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql+asyncpg://consentos:<POSTGRES_PASSWORD>@postgres:5432/consentos
|
||||||
|
POSTGRES_USER=consentos
|
||||||
|
POSTGRES_PASSWORD=<generate with openssl rand -hex 24>
|
||||||
|
POSTGRES_DB=consentos
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://default:<REDIS_PASSWORD>@redis:6379/0
|
||||||
|
REDIS_PASSWORD=<generate with openssl rand -hex 24>
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=<generate with openssl rand -hex 32>
|
||||||
|
|
||||||
|
# CDN — same origin as the admin UI in this setup
|
||||||
|
CDN_BASE_URL=https://cmp.example.com
|
||||||
|
|
||||||
|
# CORS — admin origin + every customer site embedding the banner
|
||||||
|
ALLOWED_ORIGINS=https://cmp.example.com,https://www.example.com
|
||||||
|
|
||||||
|
# Initial admin
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=<strong temporary password>
|
||||||
|
|
||||||
|
# GeoIP — if behind Cloudflare, country detection works automatically.
|
||||||
|
# For state/region granularity behind Cloudflare Enterprise:
|
||||||
|
# GEOIP_REGION_HEADER=cf-region-code
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Start the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The init container (`consentos-bootstrap`) runs Alembic migrations and creates the initial admin user, then exits. All other services wait for it via `service_completed_successfully`.
|
||||||
|
|
||||||
|
### 1.4 Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check services
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# API health
|
||||||
|
curl http://localhost:11001/health
|
||||||
|
|
||||||
|
# Deep readiness (checks Postgres + Redis)
|
||||||
|
curl http://localhost:11001/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Reverse proxy
|
||||||
|
|
||||||
|
The API listens on `127.0.0.1:11001` and the admin UI on `127.0.0.1:11002`. Put a reverse proxy in front to terminate TLS.
|
||||||
|
|
||||||
|
**Caddy example** (`/etc/caddy/Caddyfile`):
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
cmp.example.com {
|
||||||
|
# API
|
||||||
|
handle /api/v1/* {
|
||||||
|
reverse_proxy localhost:11001
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hosted policy pages
|
||||||
|
handle /c/* {
|
||||||
|
reverse_proxy localhost:11001
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
handle /health {
|
||||||
|
reverse_proxy localhost:11001
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin UI + banner CDN (catch-all, must be last)
|
||||||
|
reverse_proxy localhost:11002
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy handles TLS automatically via Let's Encrypt. Reload after creating the file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx example** (`/etc/nginx/sites-enabled/consentos`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name cmp.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/cmp.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/cmp.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:11001;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /c/ {
|
||||||
|
proxy_pass http://127.0.0.1:11001;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:11001;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:11002;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 Integrate the banner
|
||||||
|
|
||||||
|
Add the loader to every page on your customer site, **as the very first `<script>` in `<head>`** — no `async`, no `defer`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://cmp.example.com/consent-loader.js"
|
||||||
|
data-site-id="<site-id-from-admin-ui>"
|
||||||
|
data-api-base="https://cmp.example.com"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Critical**: the loader must run synchronously before any other script. If another script executes first (e.g. Google Tag Manager), it can set cookies before the blocker is installed. The loader will sweep classified pre-existing cookies on load, but `Set-Cookie` response headers from network requests cannot be intercepted from JavaScript — only blocking the source script prevents those.
|
||||||
|
|
||||||
|
### 1.7 Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/consentos
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap init container runs migrations automatically on every start, so schema updates are applied without manual intervention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kubernetes (Helm)
|
||||||
|
|
||||||
|
For multi-node deployments with auto-scaling, rolling updates, and integration with managed databases and Redis.
|
||||||
|
|
||||||
|
### 2.1 Prerequisites
|
||||||
|
|
||||||
|
- A Kubernetes cluster (1.24+)
|
||||||
|
- Helm 3
|
||||||
|
- Container images pushed to a registry (GHCR, ECR, GCR, etc.)
|
||||||
|
- A managed PostgreSQL instance (recommended) or an in-cluster one
|
||||||
|
- A managed Redis instance (recommended) or an in-cluster one
|
||||||
|
|
||||||
|
### 2.2 Build and push images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API + Celery worker/beat (same image, different entrypoint)
|
||||||
|
docker build -t ghcr.io/consentos/consentos-api:latest apps/api/
|
||||||
|
docker push ghcr.io/consentos/consentos-api:latest
|
||||||
|
|
||||||
|
# Scanner
|
||||||
|
docker build -t ghcr.io/consentos/consentos-scanner:latest apps/scanner/
|
||||||
|
docker push ghcr.io/consentos/consentos-scanner:latest
|
||||||
|
|
||||||
|
# Admin UI + banner (build context = repo root)
|
||||||
|
docker build -f apps/admin-ui/Dockerfile -t ghcr.io/consentos/consentos-admin-ui:latest .
|
||||||
|
docker push ghcr.io/consentos/consentos-admin-ui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Create a values override
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# values.prod.yaml
|
||||||
|
|
||||||
|
api:
|
||||||
|
replicaCount: 3
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: production
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
ALLOWED_ORIGINS: "https://cmp.example.com,https://www.example.com"
|
||||||
|
CDN_BASE_URL: "https://cmp.example.com"
|
||||||
|
SCANNER_SERVICE_URL: "http://consentos-scanner:8001"
|
||||||
|
# GeoIP — behind Cloudflare, country resolves automatically.
|
||||||
|
# For state-level behind Cloudflare Enterprise:
|
||||||
|
# GEOIP_REGION_HEADER: cf-region-code
|
||||||
|
# Or mount a MaxMind DB and set:
|
||||||
|
# GEOIP_MAXMIND_DB_PATH: /data/GeoLite2-City.mmdb
|
||||||
|
|
||||||
|
scanner:
|
||||||
|
replicaCount: 1
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
|
||||||
|
adminUi:
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
|
# Use managed Postgres (e.g. Cloud SQL, RDS)
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
externalUrl: "postgresql+asyncpg://consentos:<PASSWORD>@<HOST>:5432/consentos"
|
||||||
|
|
||||||
|
# Use managed Redis (e.g. Memorystore, ElastiCache)
|
||||||
|
redis:
|
||||||
|
enabled: false
|
||||||
|
externalUrl: "redis://default:<PASSWORD>@<HOST>:6379/0"
|
||||||
|
|
||||||
|
# Ingress (nginx-ingress or similar)
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: cmp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
service: api
|
||||||
|
- path: /c
|
||||||
|
pathType: Prefix
|
||||||
|
service: api
|
||||||
|
- path: /health
|
||||||
|
pathType: Prefix
|
||||||
|
service: api
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
service: admin-ui
|
||||||
|
tls:
|
||||||
|
- secretName: consentos-tls
|
||||||
|
hosts:
|
||||||
|
- cmp.example.com
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
secrets:
|
||||||
|
jwtSecretKey: "<generate with openssl rand -hex 32>"
|
||||||
|
postgresqlPassword: "<your managed DB password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Install the chart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install consentos helm/consentos/ \
|
||||||
|
-f values.prod.yaml \
|
||||||
|
--namespace consentos \
|
||||||
|
--create-namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Run the bootstrap
|
||||||
|
|
||||||
|
The Helm chart doesn't include an init container by default. Run the bootstrap as a one-off Kubernetes Job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl run consentos-bootstrap \
|
||||||
|
--namespace consentos \
|
||||||
|
--image ghcr.io/consentos/consentos-api:latest \
|
||||||
|
--restart=Never \
|
||||||
|
--env="DATABASE_URL=postgresql+asyncpg://consentos:<PW>@<HOST>:5432/consentos" \
|
||||||
|
--env="INITIAL_ADMIN_EMAIL=admin@example.com" \
|
||||||
|
--env="INITIAL_ADMIN_PASSWORD=<temporary password>" \
|
||||||
|
--env="JWT_SECRET_KEY=<your key>" \
|
||||||
|
--env="ENVIRONMENT=production" \
|
||||||
|
--command -- sh -c "python -m alembic upgrade head && python -m src.cli.bootstrap_admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for it to complete, then delete the pod:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl wait --for=condition=Ready pod/consentos-bootstrap -n consentos --timeout=120s
|
||||||
|
kubectl delete pod consentos-bootstrap -n consentos
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods -n consentos
|
||||||
|
curl https://cmp.example.com/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild and push images with a new tag
|
||||||
|
docker build -t ghcr.io/consentos/consentos-api:v1.2.0 apps/api/
|
||||||
|
docker push ghcr.io/consentos/consentos-api:v1.2.0
|
||||||
|
|
||||||
|
# Upgrade the Helm release
|
||||||
|
helm upgrade consentos helm/consentos/ \
|
||||||
|
-f values.prod.yaml \
|
||||||
|
--set api.image.tag=v1.2.0 \
|
||||||
|
--set scanner.image.tag=v1.2.0 \
|
||||||
|
--set adminUi.image.tag=v1.2.0 \
|
||||||
|
--namespace consentos
|
||||||
|
```
|
||||||
|
|
||||||
|
Helm performs a rolling update. The API Dockerfile runs migrations on startup (the Dockerfile's `CMD` includes `alembic upgrade head`), so schema updates are applied automatically as new pods come up.
|
||||||
|
|
||||||
|
> **Note**: In the Docker Compose deployment, migrations are owned by the init container, and the API's `CMD` only runs `uvicorn`. In Kubernetes, since there's no native "init container completes first" guarantee across separate Deployments, each API pod runs its own `alembic upgrade head` on startup. Alembic migrations are idempotent, so multiple pods running them concurrently is safe.
|
||||||
|
|
||||||
|
### 2.8 GeoIP with MaxMind on Kubernetes
|
||||||
|
|
||||||
|
If you need local MaxMind lookups (e.g. behind a load balancer that doesn't inject GeoIP headers), mount the database file via a PersistentVolumeClaim or a ConfigMap:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In values.prod.yaml
|
||||||
|
api:
|
||||||
|
env:
|
||||||
|
GEOIP_MAXMIND_DB_PATH: /data/GeoLite2-City.mmdb
|
||||||
|
extraVolumes:
|
||||||
|
- name: geoip-db
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: geoip-db
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: geoip-db
|
||||||
|
mountPath: /data
|
||||||
|
readOnly: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a CronJob to refresh the MaxMind database weekly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create cronjob geoip-update \
|
||||||
|
--namespace consentos \
|
||||||
|
--schedule="0 3 * * 0" \
|
||||||
|
--image maxmindinc/geoipupdate \
|
||||||
|
--env="GEOIPUPDATE_ACCOUNT_ID=<your-id>" \
|
||||||
|
--env="GEOIPUPDATE_LICENSE_KEY=<your-key>" \
|
||||||
|
--env="GEOIPUPDATE_EDITION_IDS=GeoLite2-City"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cloud Run / Serverless
|
||||||
|
|
||||||
|
For teams that want managed scaling, zero cold-infrastructure, and pay-per-request pricing. This guide uses Google Cloud Run as the reference, but the pattern adapts to AWS App Runner, Azure Container Apps, or Fly.io.
|
||||||
|
|
||||||
|
### 3.1 Architecture
|
||||||
|
|
||||||
|
| Component | Service | Notes |
|
||||||
|
|-----------|---------|-------|
|
||||||
|
| API | Cloud Run service | Scales to zero. Connects to Cloud SQL + Memorystore. |
|
||||||
|
| Admin UI + banner | Cloud Run service (or Cloud Storage + CDN) | Static files — can also be served from a GCS bucket behind Cloud CDN. |
|
||||||
|
| Celery worker | Cloud Run Job or always-on instance (min 1) | Must be always-on to process the Redis queue. Cloud Run Jobs work for batch processing but not for long-polling Celery workers — use an always-on revision with `--min-instances=1`. |
|
||||||
|
| Celery beat | Cloud Run Job (scheduled) or Cloud Scheduler + Pub/Sub | Triggers periodic tasks. Alternatively, use Cloud Scheduler to invoke the API's scan endpoints directly. |
|
||||||
|
| Scanner | Cloud Run service (or separate VM) | Needs 1 GB+ RAM and `/dev/shm` > 64 MB for Playwright/Chromium. Cloud Run supports custom `/dev/shm` sizes via `--execution-environment=gen2`. |
|
||||||
|
| PostgreSQL | Cloud SQL | Managed, auto-backups, replicas. |
|
||||||
|
| Redis | Memorystore for Redis | Or Upstash for a serverless Redis. |
|
||||||
|
|
||||||
|
### 3.2 Build and push images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tag for Artifact Registry (or Container Registry)
|
||||||
|
export REGION=europe-west1
|
||||||
|
export PROJECT=my-gcp-project
|
||||||
|
export REGISTRY=${REGION}-docker.pkg.dev/${PROJECT}/consentos
|
||||||
|
|
||||||
|
docker build -t ${REGISTRY}/api:latest apps/api/
|
||||||
|
docker build -t ${REGISTRY}/scanner:latest apps/scanner/
|
||||||
|
docker build -f apps/admin-ui/Dockerfile -t ${REGISTRY}/admin-ui:latest .
|
||||||
|
|
||||||
|
docker push ${REGISTRY}/api:latest
|
||||||
|
docker push ${REGISTRY}/scanner:latest
|
||||||
|
docker push ${REGISTRY}/admin-ui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Provision managed infrastructure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cloud SQL (Postgres 16)
|
||||||
|
gcloud sql instances create consentos-db \
|
||||||
|
--database-version=POSTGRES_16 \
|
||||||
|
--tier=db-f1-micro \
|
||||||
|
--region=${REGION} \
|
||||||
|
--root-password=<POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
gcloud sql databases create consentos --instance=consentos-db
|
||||||
|
gcloud sql users create consentos --instance=consentos-db --password=<POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# Memorystore (Redis 7)
|
||||||
|
gcloud redis instances create consentos-redis \
|
||||||
|
--size=1 \
|
||||||
|
--region=${REGION} \
|
||||||
|
--redis-version=redis_7_0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Deploy the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run deploy consentos-api \
|
||||||
|
--image ${REGISTRY}/api:latest \
|
||||||
|
--region ${REGION} \
|
||||||
|
--platform managed \
|
||||||
|
--allow-unauthenticated \
|
||||||
|
--min-instances=1 \
|
||||||
|
--max-instances=10 \
|
||||||
|
--memory=512Mi \
|
||||||
|
--cpu=1 \
|
||||||
|
--port=8000 \
|
||||||
|
--set-env-vars="ENVIRONMENT=production" \
|
||||||
|
--set-env-vars="DATABASE_URL=postgresql+asyncpg://consentos:<PW>@<CLOUD_SQL_IP>:5432/consentos" \
|
||||||
|
--set-env-vars="REDIS_URL=redis://<MEMORYSTORE_IP>:6379/0" \
|
||||||
|
--set-env-vars="JWT_SECRET_KEY=<your-key>" \
|
||||||
|
--set-env-vars="CDN_BASE_URL=https://cmp.example.com" \
|
||||||
|
--set-env-vars="ALLOWED_ORIGINS=https://cmp.example.com,https://www.example.com" \
|
||||||
|
--set-env-vars="SCANNER_SERVICE_URL=https://consentos-scanner-<hash>.run.app" \
|
||||||
|
--set-env-vars="INITIAL_ADMIN_EMAIL=admin@example.com" \
|
||||||
|
--set-env-vars="INITIAL_ADMIN_PASSWORD=<temp-pw>" \
|
||||||
|
--add-cloudsql-instances=${PROJECT}:${REGION}:consentos-db \
|
||||||
|
--vpc-connector=consentos-vpc-connector
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip**: Use Secret Manager for sensitive values instead of inline `--set-env-vars`:
|
||||||
|
> ```bash
|
||||||
|
> --set-secrets="JWT_SECRET_KEY=jwt-secret:latest,POSTGRES_PASSWORD=pg-password:latest"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 3.5 Deploy the admin UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run deploy consentos-admin \
|
||||||
|
--image ${REGISTRY}/admin-ui:latest \
|
||||||
|
--region ${REGION} \
|
||||||
|
--platform managed \
|
||||||
|
--allow-unauthenticated \
|
||||||
|
--min-instances=0 \
|
||||||
|
--max-instances=5 \
|
||||||
|
--memory=128Mi \
|
||||||
|
--cpu=1 \
|
||||||
|
--port=80
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Deploy the scanner
|
||||||
|
|
||||||
|
The scanner needs generous memory and `/dev/shm` for Playwright:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run deploy consentos-scanner \
|
||||||
|
--image ${REGISTRY}/scanner:latest \
|
||||||
|
--region ${REGION} \
|
||||||
|
--platform managed \
|
||||||
|
--no-allow-unauthenticated \
|
||||||
|
--min-instances=0 \
|
||||||
|
--max-instances=3 \
|
||||||
|
--memory=1Gi \
|
||||||
|
--cpu=2 \
|
||||||
|
--port=8001 \
|
||||||
|
--execution-environment=gen2 \
|
||||||
|
--set-env-vars="CRAWLER_HEADLESS=true,LOG_LEVEL=INFO"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 Deploy the Celery worker
|
||||||
|
|
||||||
|
Cloud Run isn't ideal for long-running Celery workers (it expects request-driven traffic). Options:
|
||||||
|
|
||||||
|
**Option A — always-on Cloud Run revision:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run deploy consentos-worker \
|
||||||
|
--image ${REGISTRY}/api:latest \
|
||||||
|
--region ${REGION} \
|
||||||
|
--platform managed \
|
||||||
|
--no-allow-unauthenticated \
|
||||||
|
--min-instances=1 \
|
||||||
|
--max-instances=3 \
|
||||||
|
--memory=512Mi \
|
||||||
|
--cpu=1 \
|
||||||
|
--no-cpu-throttling \
|
||||||
|
--command="celery","-A","src.celery_app","worker","--loglevel=info","--concurrency=2" \
|
||||||
|
--set-env-vars="DATABASE_URL=...,REDIS_URL=...,SCANNER_SERVICE_URL=..." \
|
||||||
|
--vpc-connector=consentos-vpc-connector
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — Compute Engine (GCE) or a small GKE node** running just the Celery worker and beat. Simpler, cheaper for steady-state workloads.
|
||||||
|
|
||||||
|
### 3.8 Run the bootstrap
|
||||||
|
|
||||||
|
Run as a one-off Cloud Run Job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud run jobs create consentos-bootstrap \
|
||||||
|
--image ${REGISTRY}/api:latest \
|
||||||
|
--region ${REGION} \
|
||||||
|
--command="sh","-c","python -m alembic upgrade head && python -m src.cli.bootstrap_admin" \
|
||||||
|
--set-env-vars="DATABASE_URL=...,INITIAL_ADMIN_EMAIL=...,INITIAL_ADMIN_PASSWORD=...,JWT_SECRET_KEY=...,ENVIRONMENT=production" \
|
||||||
|
--vpc-connector=consentos-vpc-connector
|
||||||
|
|
||||||
|
gcloud run jobs execute consentos-bootstrap --region ${REGION} --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.9 Set up routing
|
||||||
|
|
||||||
|
Use a Google Cloud Load Balancer (or Cloudflare in front) to route:
|
||||||
|
|
||||||
|
| Path | Backend |
|
||||||
|
|------|---------|
|
||||||
|
| `/api/v1/*` | `consentos-api` Cloud Run service |
|
||||||
|
| `/c/*` | `consentos-api` Cloud Run service |
|
||||||
|
| `/health` | `consentos-api` Cloud Run service |
|
||||||
|
| `/*` (default) | `consentos-admin` Cloud Run service |
|
||||||
|
|
||||||
|
If using Cloudflare as the CDN and reverse proxy, `cf-ipcountry` is injected automatically — no `GEOIP_COUNTRY_HEADER` env var needed. For state-level granularity with Cloudflare Enterprise, set `GEOIP_REGION_HEADER=cf-region-code`.
|
||||||
|
|
||||||
|
If using Google Cloud Load Balancer directly (no Cloudflare), set:
|
||||||
|
|
||||||
|
```
|
||||||
|
GEOIP_COUNTRY_HEADER=x-gclb-country
|
||||||
|
GEOIP_REGION_HEADER=x-gclb-region
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.10 GeoIP considerations for serverless
|
||||||
|
|
||||||
|
Serverless platforms don't have a persistent filesystem for MaxMind databases. Your options:
|
||||||
|
|
||||||
|
1. **CDN headers** (recommended) — Cloudflare, Vercel, and GCP Load Balancer all inject country headers. Zero config beyond the env var.
|
||||||
|
2. **Mount from GCS** — Use a GCS FUSE volume mount to expose the `.mmdb` file:
|
||||||
|
```bash
|
||||||
|
gcloud run deploy consentos-api \
|
||||||
|
--add-volume=name=geoip,type=cloud-storage,bucket=my-geoip-bucket \
|
||||||
|
--add-volume-mount=volume=geoip,mount-path=/data \
|
||||||
|
--set-env-vars="GEOIP_MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb"
|
||||||
|
```
|
||||||
|
3. **Bake into the image** — Copy the `.mmdb` into the Dockerfile. Simple but stale until you rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Banner Integration Checklist
|
||||||
|
|
||||||
|
Regardless of deployment method, verify these before going live:
|
||||||
|
|
||||||
|
- [ ] `consent-loader.js` is the **very first `<script>` in `<head>`** on every customer page. No `async`. No `defer`.
|
||||||
|
- [ ] `data-site-id` and `data-api-base` attributes are set correctly on the script tag.
|
||||||
|
- [ ] The API's `ALLOWED_ORIGINS` includes every customer site origin that embeds the banner.
|
||||||
|
- [ ] `CDN_BASE_URL` points at the origin where `consent-loader.js` and `consent-bundle.js` are served (same as the admin UI in a standard deployment).
|
||||||
|
- [ ] Google Tag Manager (if used) is loaded **after** the ConsentOS loader, not before.
|
||||||
|
- [ ] The consent cookie (`_consentos_consent`) is accessible on the customer domain — check that `SameSite=Lax` and the domain/path are correct.
|
||||||
|
- [ ] Regional modes are configured in the admin UI for any site that needs location-aware consent (e.g. opt-in for EU, opt-out for US-CA).
|
||||||
|
- [ ] GeoIP headers are flowing from your CDN/load balancer — verify with `curl -I https://cmp.example.com/api/v1/config/sites/<id>` and check for `cf-ipcountry` or your custom header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Password Reset
|
||||||
|
|
||||||
|
If you've forgotten your password and can't log in to the admin UI, reset it from the host machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec consentos-api python -m src.cli.reset_password \
|
||||||
|
--email admin@example.com \
|
||||||
|
--password new-secret-here
|
||||||
|
```
|
||||||
|
|
||||||
|
The password must be at least 8 characters. The change takes effect immediately — no restart needed. On Kubernetes, run it as a one-off pod:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it deploy/consentos-api -n consentos -- \
|
||||||
|
python -m src.cli.reset_password --email admin@example.com --password new-secret-here
|
||||||
|
```
|
||||||
|
|
||||||
|
Once logged back in, you can change your email and password from the **Account** page (click your name in the top nav → Account).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Fix |
|
||||||
|
|---------|-------------|-----|
|
||||||
|
| `_ga` cookie appears before consent | The ConsentOS loader isn't the first script on the page, or it's loaded with `async`/`defer`. | Move the loader to the very top of `<head>` and remove `async`/`defer`. |
|
||||||
|
| CORS error on banner config fetch | The customer site's origin isn't in `ALLOWED_ORIGINS`. | Add the origin to the comma-separated list and redeploy. |
|
||||||
|
| Scanner fails with `httpx.ConnectError` | `SCANNER_SERVICE_URL` doesn't match the scanner's actual address/port, or the scanner's port was overridden by a shared `PORT` env var. | Verify the URL and ensure the scanner uses a scoped `environment:` block, not `env_file: .env`. |
|
||||||
|
| API refuses to start: "unsafe configuration" | `JWT_SECRET_KEY` is the placeholder value, or `ALLOWED_ORIGINS` contains `*`, and `ENVIRONMENT` is set to `production`. | Set real values for both. |
|
||||||
|
| Cookies still blocked after accepting consent | The loader and banner bundle are separate IIFEs with independent module state. If `window.__consentos._updateBlocker` is missing, the bundle can't drive the loader's blocker. | Upgrade to the latest version — the bridge was added in the `fix/blocker-loader-bundle-bridge` PR. |
|
||||||
|
| Pre-existing tracker cookies survive after declining | The sweep only deletes cookies matching known patterns (`_ga`, `_fbp`, etc.). Unknown cookie names fall through. | Add the cookie to the scanner's known-cookies database via the admin UI, or extend the patterns in `blocker.ts`. |
|
||||||
39
entrypoint.sh
Normal file
39
entrypoint.sh
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Extract host and port from DATABASE_URL
|
||||||
|
DB_HOST=$(echo "$DATABASE_URL" | sed -E 's|.*@([^/:]+).*|\1|')
|
||||||
|
DB_PORT=$(echo "$DATABASE_URL" | sed -E 's|.*@[^/:]+:([0-9]+)/.*|\1|')
|
||||||
|
|
||||||
|
if [ -z "$DB_PORT" ] || [ "$DB_PORT" = "$DB_HOST" ]; then
|
||||||
|
DB_PORT="5432"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for postgres at $DB_HOST:$DB_PORT ..."
|
||||||
|
|
||||||
|
max_retries=30
|
||||||
|
counter=0
|
||||||
|
until (
|
||||||
|
pg_isready -h "$DB_HOST" -p "$DB_PORT" -q 2>/dev/null
|
||||||
|
) || (
|
||||||
|
(echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null
|
||||||
|
); do
|
||||||
|
counter=$((counter + 1))
|
||||||
|
if [ $counter -ge $max_retries ]; then
|
||||||
|
echo "ERROR: postgres at $DB_HOST:$DB_PORT not ready after ${max_retries}s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " postgres not ready, retrying in 2s ... ($counter/$max_retries)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "postgres is ready!"
|
||||||
|
|
||||||
|
# Run alembic migrations
|
||||||
|
if [ -f /app/alembic/env.py ]; then
|
||||||
|
echo "Running database migrations ..."
|
||||||
|
python -m alembic upgrade head
|
||||||
|
echo "Migrations complete!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
70
supervisord.conf
Normal file
70
supervisord.conf
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
user=root
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=nginx -g "daemon off;" -c /etc/nginx/conf.d/default.conf
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=5
|
||||||
|
killasgroup=true
|
||||||
|
priority=100
|
||||||
|
|
||||||
|
[program:api]
|
||||||
|
command=sh -c "uvicorn src.main:app --host 127.0.0.1 --port 8000 --workers ${API_WORKERS:-4} --access-log --proxy-headers --forwarded-allow-ips '*'"
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=10
|
||||||
|
killasgroup=true
|
||||||
|
priority=200
|
||||||
|
|
||||||
|
[program:worker]
|
||||||
|
command=celery -A src.celery_app worker --loglevel=info --concurrency=2
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=30
|
||||||
|
killasgroup=true
|
||||||
|
priority=300
|
||||||
|
|
||||||
|
[program:beat]
|
||||||
|
command=celery -A src.celery_app beat --loglevel=info
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=10
|
||||||
|
killasgroup=true
|
||||||
|
priority=400
|
||||||
|
|
||||||
|
[program:scanner]
|
||||||
|
command=python -m src.worker
|
||||||
|
directory=/app
|
||||||
|
autostart=false
|
||||||
|
autorestart=false
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=10
|
||||||
|
killasgroup=true
|
||||||
|
priority=500
|
||||||
|
environment=PYTHONUNBUFFERED="1"
|
||||||
Reference in New Issue
Block a user