From 8aa65014b46b2bf499d3e3ca3dbd70faddd52b47 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 00:53:21 -0700 Subject: [PATCH] Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap item #11. Ships the non-modal bottom-of-page cookie consent banner, the default /privacy and /cookies policy pages, the `cookie_consent` table + two §17 endpoints for server-side persistence, the localStorage fallback for anonymous viewers, the /settings "Privacy & cookies" tab for revisiting the choice, and the `frontend/src/lib/consent.js` helper that roadmap item #13's analytics SDK (v0.15.0) will gate against. No analytics SDK ships in this release — the consent infrastructure goes in first so the gate is already in place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names the new table in §5, and surfaces four §19.2 candidates (content-repo file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency). Two new optional env vars (`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub pages. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 102 +++++++++ SPEC.md | 136 ++++++++++++ VERSION | 2 +- backend/app/api_notifications.py | 79 +++++++ backend/migrations/013_cookie_consent.sql | 35 +++ backend/tests/test_cookie_consent_vertical.py | 205 ++++++++++++++++++ frontend/.env.example | 25 +++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.css | 103 +++++++++ frontend/src/App.jsx | 26 +++ frontend/src/api.js | 17 ++ .../src/components/CookieConsentBanner.jsx | 164 ++++++++++++++ .../src/components/NotificationSettings.jsx | 59 +++++ frontend/src/lib/consent.js | 154 +++++++++++++ frontend/src/pages/Cookies.jsx | 125 +++++++++++ frontend/src/pages/Privacy.jsx | 118 ++++++++++ 17 files changed, 1352 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/013_cookie_consent.sql create mode 100644 backend/tests/test_cookie_consent_vertical.py create mode 100644 frontend/src/components/CookieConsentBanner.jsx create mode 100644 frontend/src/lib/consent.js create mode 100644 frontend/src/pages/Cookies.jsx create mode 100644 frontend/src/pages/Privacy.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9243cc5..5dc362b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,107 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.13.0 — 2026-05-28 + +**Minor — schema migration required; new optional env vars.** This +release ships the cookie / privacy consent surface (roadmap item #11, +SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike — +now sees a non-modal bottom-of-page banner on first visit asking which +categories of cookies they allow (essential / essential + analytics / +essential + analytics + other). The choice persists in `localStorage` +for anonymous viewers and in a new `cookie_consent` table for +authenticated viewers, with server-side overriding local on sign-in. +The framework also ships default `/privacy` and `/cookies` policy pages +that deployments can layer their own policy URL on top of via two new +optional env vars. No analytics SDK ships in this release — the +consent infrastructure is wired so item #13 (v0.15.0) can read from +`frontend/src/lib/consent.js` when the SDK lands. + +### Added + +- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`). + Non-modal, bottom of viewport. Three single-select choices with + inline descriptions. Visible until the user makes a choice; hides + thereafter. Reachable for revision via the settings surface. +- **Consent helper** (`frontend/src/lib/consent.js`). Exports + `getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`, + `hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the + `storage` event. Item #13's analytics SDK reads consent here before + importing. +- **Privacy and cookies policy pages** + (`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`). + Default minimal policies that describe the framework's stance and + list the cookies the framework sets. Deployments override via the + two new env vars below; the framework's stub always renders above + the link so the framework-level contract stays visible. +- **"Privacy & cookies" tab** in `/settings/notifications` showing + the current consent choice, the recorded-at stamp, and a "Change" + button that re-opens the banner via a custom DOM event. +- **`§17` endpoints** — + - `GET /api/users/me/cookie-consent` — read the current consent + record. + - `PUT /api/users/me/cookie-consent` — write a new consent record. + Upserts a single row per user, stamps `recorded_at` to now, + accepts `essential` for symmetry but always persists it as true. +- **Schema migration** `013_cookie_consent.sql` — new + `cookie_consent` table keyed by `user_id`, three flags + (`essential`, `analytics`, `other_cookies`), and `recorded_at`. + (Renumbered from `012_*` during driver integration because v0.7.0 + also added a `012_otc.sql` migration that landed in the integration + order before this one.) +- **SPEC `§14.5` Cookie / privacy consent** — settles the banner + shape, the three-category single-select, the storage shape (local + for anon, server row for authenticated), the precedence rule on + sign-in, and the `consent.js` helper surface for downstream + callers including item #13. +- **SPEC `§14.6` Privacy and cookies policy pages** — settles the + `/privacy` and `/cookies` routes, the framework's stub content, and + the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override + shape. +- **SPEC `§5`** — names the `cookie_consent` table in the canonical + app-tables list. +- **SPEC `§17`** — lists the two new cookie-consent endpoints. +- **SPEC `§19.2`** — surfaces four candidates: policy content via + content-repo file vs env var, GPC / DNT headers, multi-language + consent text, and the item #13 analytics-SDK gating dependency. + +### Changed + +- **`frontend/.env.example`** — documents the two new optional env + vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset + is supported; defaults render the framework's stub. +- **`backend/app/api_notifications.py`** — module docstring grew two + endpoint lines; the new endpoints sit alongside the existing + `/api/users/me/*` neighbors. +- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies` + routes (anonymous-reachable), wires `` into + the global chrome, and listens for a `rfc-app:cookie-consent-reopen` + custom event to re-open the banner from the settings surface. + +### Upgrade steps (from 0.7.0) + +- You **MUST** rebuild the frontend and restart the backend after + upgrading. `frontend/package.json#version` and `VERSION` both move + to `0.13.0` and the build embeds the new env-var contract. +- You **MUST** apply schema migration `013_cookie_consent.sql`. The + migration creates a single new table keyed by `user_id` with three + flag columns and a `recorded_at` stamp. The framework runs + migrations automatically at process start; no manual step is + required beyond restarting the backend so the migration runner + picks the file up. +- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that + points at your deployment's full privacy policy. The framework's + `/privacy` page renders its built-in stub above a link to the + configured URL. Unset is supported — the stub is sufficient for a + default-config deployment. +- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that + points at your deployment's full cookies policy. Same shape as the + privacy URL. +- You **MAY** announce the new consent banner to your users. Existing + authenticated users will see the banner on their next visit + (because their `cookie_consent` row does not yet exist); their + current sessions remain valid. + ## 0.7.0 — 2026-05-28 **Minor — schema migration required; new auth path is additive.** @@ -257,6 +358,7 @@ explicit: dependency changes; the operator's role is reduced to rebuild + restart. + ## 0.5.0 — 2026-05-27 **Minor — no operator action required.** This release wires the diff --git a/SPEC.md b/SPEC.md index b777529..df625f8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -332,6 +332,13 @@ and exact columns are illustrative; the implementing session can adjust. - `actions` — append-only audit log for every state transition, every graduation, every grant change. Includes the acting user, the bot commit hash if any, and the on-behalf-of trailer applied. +- `cookie_consent` — per-user record of the §14.5 cookie consent + choice. One row per user. Columns: `user_id` (PK, FK users), three + flags (`essential`, `analytics`, `other_cookies`), and + `recorded_at`. `essential` is permanently 1; `recorded_at` is set + on first write and updated on every change. Absence of a row means + "no choice yet" — the banner shows. Anonymous viewers persist their + choice in `localStorage` only, with no corresponding row here. **Super-draft scoping.** For rows in `threads` and `changes` where the entry referenced by `rfc_slug` is in state `super-draft`, `branch_name` @@ -2014,6 +2021,92 @@ The visual design of the landing page and the `/philosophy` route — typography, layout, illustrations if any — is deferred. The structural decisions above are the binding part. +### 14.5 Cookie / privacy consent (v0.13.0) + +The framework ships a non-modal cookie consent banner reachable by +every viewer — authenticated and anonymous alike. The banner appears +at the bottom of the viewport on first load and stays visible until +the user makes a choice, after which it hides and the choice is +persisted. The `/settings/notifications` page carries a "Privacy & +cookies" tab that surfaces the current choice and re-opens the banner +on demand. + +The choice has three categories, presented as a single-select: + +- **Essential only** — the framework's strictly-necessary cookies + (sign-in session, signed payloads, the consent-choice record + itself). Always on; the user cannot switch this off because the + app cannot function without it. +- **Essential + analytics** — adds the optional analytics layer + gated by this choice. As of v0.13.0 no analytics SDK ships in the + framework; roadmap item #13 (v0.15.0) lands one behind this gate. + Off by default — the user has to opt in. +- **Essential + analytics + other** — adds third-party embeds or + social widgets a deployment may configure. The framework ships no + such cookies by default; this category exists so deployments that + add them have a categorized opt-in to wire them behind. + +Storage shape: + +- **Anonymous viewer** — choice persists in `localStorage` only + (`rfc-app.cookie-consent.v1`). The same browser carries the choice + forward; a different browser, or cleared storage, re-prompts. +- **Authenticated viewer** — choice persists in the `cookie_consent` + row keyed by `user_id`. On sign-in, the server row (if present) + overrides the local snapshot; if the server has no row, the local + choice is uploaded. + +The `essential` flag is permanently true at the API surface. The +endpoint accepts it for symmetry but never persists a false value. +A deployment that wants strictly-necessary cookies to be optional +must change the framework contract, not flip a flag. + +The framework exports a small JavaScript helper (`frontend/src/lib/ +consent.js`) for downstream surfaces: + +- `getConsent()` — current snapshot. +- `hasChosen()` — true once the user has made a choice. +- `onConsentChange(cb)` — subscribe to updates. +- `setConsent({analytics, other})` — record a new choice locally + (the banner / settings surface handles server persistence on top). + +Roadmap item #13's analytics SDK (v0.15.0) will read from this helper: +read consent, then conditionally `import()` the SDK module. The gate +is wired before the SDK lands so the contract is already in place. + +### 14.6 Privacy and cookies policy pages + +The framework ships two policy routes: + +- `/privacy` — a minimal default privacy policy that describes the + framework's stance (what is stored, why, how to revoke consent, + how to reach the deployment operator). The page is reachable by + anonymous and authenticated viewers alike. +- `/cookies` — the framework's cookies policy, listing exactly which + cookies the framework sets, by category. Self-documenting: a future + framework release that adds or removes a cookie updates this page + as part of the change. + +Each page links to the other and to the §14.5 banner. The consent +banner links to both. + +Deployments override the policy content via two optional build-time +env vars documented in `frontend/.env.example`: + +- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page + links to as the "full deployment policy". The framework's stub + always renders above the link so the framework-level contract is + always visible; the link layers deployment-specific content on + top. +- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`. + +Both are optional. Unset is the supported default; the stub pages are +sufficient for a default-config deployment that has nothing +deployment-specific to add. The framework chose the env-var path over +a content-repo file because it composes with the existing build-time +config layer; the content-repo-file alternative is the §19.2 +candidate. + --- ## 15. Notifications @@ -2785,6 +2878,16 @@ The follow-up session will refine this. A minimal starting set: short confirmation page. - `POST /api/webhooks/email-bounce` — bounce and complaint receiver per §15.4; sets the recipient's global email opt-out. +- `GET /api/users/me/cookie-consent` — read the signed-in user's + cookie consent record per §14.5. Returns `{essential, analytics, + other, recorded_at}`. `recorded_at: null` means "no choice yet" + and the banner should be shown; the framework treats absence of a + row as equivalent to that. `essential` is permanently true. +- `PUT /api/users/me/cookie-consent` — write the signed-in user's + cookie consent record per §14.5. Body: `{essential, analytics, + other}`. The `essential` flag is accepted for symmetry but always + persisted as true. Upserts (a single row per user) and stamps + `recorded_at` to now. Plus all the chat / streaming / model-picker endpoints, scoped to per-RFC and per-branch threads. @@ -3579,6 +3682,39 @@ quiet-hours, per-user mute, unsubscribe, bounce webhook). harness mocks the challenge. Earns its session as the v0.12.0 design pass. +Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5 +and §14.6): + +- **Policy content via content-repo file vs env var.** v0.13.0 + shipped the deployment-policy-override path as two env vars + (`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the + framework's stub pages link out to. The alternative — accepting + a markdown file path the framework renders inline, parallel to + `PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose: + a deployment could carry both an inline file (rendered above + the fold) and an external link (rendered below). Earns its own + topic when a real deployment ships a policy long enough that + the link-out shape bites and renders the link unread. +- **Global Privacy Control / Do-Not-Track headers.** v0.13.0 + scoped the consent surface to the in-app banner and did not + honor browser-side GPC or DNT signals. The framework's stance + is that the in-app banner is the authoritative gesture — a + user who clears their consent in the banner has expressed + intent, and the GPC header is a coarser signal layered on top. + Earns its own topic if a regulatory regime emerges that treats + GPC as the legally-binding gesture, in which case the framework + would honor GPC as an automatic "essential only" choice unless + the user explicitly broadened it in-app. +- **Multi-language consent text.** The banner ships English-only. + i18n of the framework's user-facing strings is a broader topic + than the consent banner; carrying the work in that future topic + rather than as a per-surface translation pass. +- **Analytics SDK gating against `consent.js`.** Roadmap item #13 + (target v0.15.0) lands the analytics SDK behind + `lib/consent.js`'s `getConsent().analytics` gate. The framework + contract is already in place; the SDK integration is the work + the item ships. Listed here so the dependency is documented. + ### 19.3 Working agreement for the queue Pre-build sessions ran on the queue agreement from prior versions diff --git a/VERSION b/VERSION index faef31a..54d1a4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.13.0 diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index 2f0ed1a..c431b43 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -14,6 +14,8 @@ The endpoints in this module are: - `POST /api/users/me/quiet-hours` — set / clear - `POST /api/users//notification-mute` — §15.8 - `DELETE /api/users//notification-mute` — §15.8 + - `GET /api/users/me/cookie-consent` — §14.5 + - `PUT /api/users/me/cookie-consent` — §14.5 - `GET /api/email/unsubscribe` — §15.4 one-click - `POST /api/webhooks/email-bounce` — §15.4 receiver @@ -73,6 +75,15 @@ class BounceBody(BaseModel): kind: str = Field(default="hard") # 'hard' or 'complaint' +class CookieConsentBody(BaseModel): + # `essential` is always true at the surface; we accept it for symmetry + # but never persist a false value (the framework's strictly-necessary + # cookies are not user-optional per SPEC §14.5). + essential: bool = True + analytics: bool = False + other: bool = False + + # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- @@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter: ) return {"ok": True} + # ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) ----- + # + # The shape is intentionally small: three flags + a recorded-at stamp. + # The banner's local-vs-server precedence rule lives in the frontend + # (`consent.js`): on sign-in, the server row (if any) overrides local; + # otherwise local is uploaded. + + @router.get("/api/users/me/cookie-consent") + async def get_cookie_consent(request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + row = db.conn().execute( + """ + SELECT essential, analytics, other_cookies, recorded_at + FROM cookie_consent WHERE user_id = ? + """, + (viewer.user_id,), + ).fetchone() + if row is None: + return { + "essential": True, + "analytics": False, + "other": False, + "recorded_at": None, + } + return { + "essential": bool(row["essential"]), + "analytics": bool(row["analytics"]), + "other": bool(row["other_cookies"]), + "recorded_at": row["recorded_at"], + } + + @router.put("/api/users/me/cookie-consent") + async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]: + viewer = auth.require_user(request) + # `essential` is the framework's strictly-necessary set; the + # surface accepts the flag for symmetry but never persists a + # false value. SPEC §14.5: a deployment that wants to make + # session-cookie storage optional must change the framework + # contract, not flip a flag here. + db.conn().execute( + """ + INSERT INTO cookie_consent + (user_id, essential, analytics, other_cookies, recorded_at) + VALUES (?, 1, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + essential = 1, + analytics = excluded.analytics, + other_cookies = excluded.other_cookies, + recorded_at = excluded.recorded_at + """, + ( + viewer.user_id, + 1 if body.analytics else 0, + 1 if body.other else 0, + ), + ) + row = db.conn().execute( + "SELECT recorded_at FROM cookie_consent WHERE user_id = ?", + (viewer.user_id,), + ).fetchone() + return { + "ok": True, + "essential": True, + "analytics": bool(body.analytics), + "other": bool(body.other), + "recorded_at": row["recorded_at"] if row else None, + } + # ----- Email: one-click unsubscribe + bounce webhook ----- @router.get("/api/email/unsubscribe") diff --git a/backend/migrations/013_cookie_consent.sql b/backend/migrations/013_cookie_consent.sql new file mode 100644 index 0000000..ad0d279 --- /dev/null +++ b/backend/migrations/013_cookie_consent.sql @@ -0,0 +1,35 @@ +-- v0.13.0 / roadmap item #11 — cookie consent. +-- +-- The framework now ships a non-modal cookie consent banner per the +-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers +-- get their choice persisted server-side so it survives sign-out / +-- sign-in across devices; anonymous viewers persist their choice in +-- localStorage only. +-- +-- Shape: a single row per user, three flags, plus a recorded-at stamp. +-- The flags are: +-- - essential: the framework's strictly-necessary cookies (session, +-- itsdangerous-signed payloads, CSRF if any). Permanently +-- true at the API surface — included in the row for +-- symmetry with the analytics / other flags rather than +-- because the user can switch it off. +-- - analytics: reserved for the §13 analytics SDK gating that lands +-- in v0.15.0. Off by default; opt-in via the banner. +-- - other: everything else (third-party embeds, social widgets). +-- Off by default; opt-in via the banner. +-- +-- A NULL recorded_at means "no choice yet" — the banner should re-prompt +-- the next time the user signs in on a fresh device. Once recorded_at is +-- set, the banner is hidden until the user re-opens it from the +-- /settings/notifications "Privacy & cookies" tab. +-- +-- The row is created lazily on first PUT. Absence of a row is equivalent +-- to NULL recorded_at — the banner shows. + +CREATE TABLE cookie_consent ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)), + analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)), + other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)), + recorded_at TEXT +); diff --git a/backend/tests/test_cookie_consent_vertical.py b/backend/tests/test_cookie_consent_vertical.py new file mode 100644 index 0000000..3144810 --- /dev/null +++ b/backend/tests/test_cookie_consent_vertical.py @@ -0,0 +1,205 @@ +"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent. + +Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and +the §14.5 storage contract: + + * GET on a fresh user returns no-choice-yet (recorded_at is None, + essential=True, analytics=False, other=False). + * PUT writes a row, stamps recorded_at, and the choice survives. + * PUT with `analytics=true, other=false` round-trips faithfully. + * `essential` is permanently true at the API surface — a PUT that + requests essential=false is still persisted with essential=true. + * The endpoint requires authentication (401 for anon). + * A second PUT updates the existing row in place (single row per + user, recorded_at re-stamps). + * Choice persists across sign-out / sign-in. +""" +from __future__ import annotations + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.get("/api/users/me/cookie-consent") + assert r.status_code == 200, r.text + body = r.json() + assert body["essential"] is True + assert body["analytics"] is False + assert body["other"] is False + assert body["recorded_at"] is None + + +def test_put_cookie_consent_records_choice(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": True, "other": False}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["essential"] is True + assert body["analytics"] is True + assert body["other"] is False + assert body["recorded_at"] is not None + + # Round-trip the read endpoint. + r = client.get("/api/users/me/cookie-consent") + body = r.json() + assert body["essential"] is True + assert body["analytics"] is True + assert body["other"] is False + assert body["recorded_at"] is not None + + +def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea): + """§14.5: `essential` is permanently true at the API surface. A + request that sets it to false is accepted (for symmetry with the + other two flags) but persisted as true. + """ + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.put( + "/api/users/me/cookie-consent", + json={"essential": False, "analytics": False, "other": False}, + ) + assert r.status_code == 200, r.text + assert r.json()["essential"] is True + + # Confirm at the schema layer too — the persisted row has essential=1. + row = db.conn().execute( + "SELECT essential FROM cookie_consent WHERE user_id = ?", + (2,), + ).fetchone() + assert row["essential"] == 1 + + +def test_cookie_consent_requires_auth(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.get("/api/users/me/cookie-consent") + assert r.status_code == 401, r.text + r = client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": True, "other": True}, + ) + assert r.status_code == 401, r.text + + +def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea): + """A second PUT updates the existing row rather than inserting a new + one. Verifies the §14.5 single-row-per-user shape. + """ + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": True, "other": False}, + ) + client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": False, "other": True}, + ) + + rows = db.conn().execute( + "SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?", + (2,), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["analytics"] == 0 + assert rows[0]["other_cookies"] == 1 + + +def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea): + """§14.5 precedence: the server row survives sign-out / sign-in. + """ + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": True, "other": True}, + ) + + # Simulate sign-out by clearing the session cookie. + client.cookies.clear() + + # Anonymous viewer cannot read. + r = client.get("/api/users/me/cookie-consent") + assert r.status_code == 401 + + # Sign back in as Alice. The server row is still there. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.get("/api/users/me/cookie-consent") + body = r.json() + assert body["analytics"] is True + assert body["other"] is True + assert body["recorded_at"] is not None + + +def test_two_users_have_independent_rows(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": True, "other": False}, + ) + + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + client.put( + "/api/users/me/cookie-consent", + json={"essential": True, "analytics": False, "other": False}, + ) + + # Each user reads their own row. + r = client.get("/api/users/me/cookie-consent").json() + assert r["analytics"] is False # Bob's + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.get("/api/users/me/cookie-consent").json() + assert r["analytics"] is True # Alice's diff --git a/frontend/.env.example b/frontend/.env.example index 3dac9f8..5a85901 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -24,3 +24,28 @@ VITE_APP_NAME= # VITE_BETA_CONTACT=ben@wiggleverse.org # VITE_BETA_CONTACT=DM @ben on Matrix VITE_BETA_CONTACT= + +# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5). +# The framework ships a minimal default privacy policy at `/privacy` +# that describes the framework's stance and lists the cookies the +# framework sets. When this var is set to an http(s) URL, the page +# renders the framework's stub above a link to the configured URL — +# deployments use this to layer their own policy content on top +# without forking the framework. Unset is OK; the stub is sufficient +# for a deployment that has nothing specific to add. +# +# Examples: +# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy +VITE_PRIVACY_POLICY_URL= + +# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6). +# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default +# `/cookies` page lists exactly which cookies the framework sets +# (rfc_session, the consent-choice localStorage entry); a deployment +# that adds its own cookies (analytics SDK once #13 lands, third-party +# embeds) points this var at a page that documents the full list. +# Unset is OK; the stub is sufficient for a default-config deployment. +# +# Examples: +# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies +VITE_COOKIES_POLICY_URL= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b595336..00068c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.7.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.7.0", + "version": "0.13.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 01f0754..cdc9024 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.7.0", + "version": "0.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 972c6a6..666bc60 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1947,3 +1947,106 @@ .discussion-readonly { font-size: 12px; color: #666; padding: 4px 0; } + +/* §14.5 — cookie consent banner (v0.13.0) */ +.cookie-consent-banner { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 1000; + background: #fff; + border-top: 1px solid #d1d5db; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08); + padding: 20px 24px; +} +.cookie-consent-body { + max-width: 880px; margin: 0 auto; + display: flex; flex-direction: column; gap: 12px; +} +.cookie-consent-title { + margin: 0; font-size: 16px; font-weight: 700; color: #111; +} +.cookie-consent-intro { + margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5; +} +.cookie-consent-choices { + border: none; padding: 0; margin: 0; + display: flex; flex-direction: column; gap: 6px; +} +.cookie-consent-choice { + display: flex; gap: 10px; align-items: flex-start; + padding: 10px 12px; border-radius: 6px; + border: 1px solid #e5e7eb; + cursor: pointer; +} +.cookie-consent-choice.is-selected { + border-color: #111; background: #f9fafb; +} +.cookie-consent-choice input[type=radio] { margin-top: 3px; } +.cookie-consent-choice-text { + display: flex; flex-direction: column; gap: 2px; +} +.cookie-consent-choice-label { + font-size: 13px; font-weight: 600; color: #111; +} +.cookie-consent-choice-desc { + font-size: 12px; color: #6b7280; line-height: 1.5; +} +.cookie-consent-links { + margin: 0; font-size: 12px; color: #6b7280; +} +.cookie-consent-links a { color: #111; text-decoration: underline; } +.cookie-consent-error { + margin: 0; font-size: 12px; color: #b91c1c; +} +.cookie-consent-actions { + display: flex; gap: 8px; justify-content: flex-end; +} +.visually-hidden { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +/* §14.5 / §14.6 — privacy + cookies policy pages */ +.policy-page { + max-width: 720px; margin: 0 auto; + padding: 0 32px 80px; +} +.policy-header { + display: flex; align-items: center; gap: 12px; + padding: 20px 0; border-bottom: 1px solid #f3f4f6; + margin-bottom: 24px; +} +.policy-back { + background: none; border: none; cursor: pointer; + color: #6b7280; font-size: 13px; padding: 4px 8px; +} +.policy-back:hover { color: #111; } +.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; } +.policy-body { line-height: 1.7; color: #111; } +.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; } +.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; } +.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; } +.policy-body p { margin: 0 0 12px; } +.policy-body ul { margin: 0 0 16px; padding-left: 22px; } +.policy-body li { margin-bottom: 6px; } +.policy-body code { + background: #f3f4f6; padding: 1px 5px; border-radius: 3px; + font-family: ui-monospace, monospace; font-size: 12px; +} +.policy-body .policy-footnote { + margin-top: 24px; font-size: 12px; color: #6b7280; +} +.policy-table { + width: 100%; border-collapse: collapse; + font-size: 13px; margin: 8px 0 16px; +} +.policy-table th, .policy-table td { + text-align: left; padding: 8px 10px; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; +} +.policy-table th { + font-size: 11px; text-transform: uppercase; + color: #6b7280; letter-spacing: 0.05em; font-weight: 600; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b6f40c9..c45ff0e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,9 @@ import Philosophy from './components/Philosophy.jsx' import NotificationSettings from './components/NotificationSettings.jsx' import Admin from './components/Admin.jsx' import ToastHost, { showToast } from './components/ToastHost.jsx' +import CookieConsentBanner from './components/CookieConsentBanner.jsx' +import Privacy from './pages/Privacy.jsx' +import Cookies from './pages/Cookies.jsx' import './App.css' export default function App() { @@ -24,8 +27,19 @@ export default function App() { const [inboxOpen, setInboxOpen] = useState(false) const [unreadCount, setUnreadCount] = useState(0) const [inboxTick, setInboxTick] = useState(0) + // §14.5: a tick that, when bumped, asks to + // re-open even if the user has already made a choice. The settings + // "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen` + // event that bumps this. + const [consentReopenTick, setConsentReopenTick] = useState(0) const navigate = useNavigate() + useEffect(() => { + const handler = () => setConsentReopenTick(t => t + 1) + window.addEventListener('rfc-app:cookie-consent-reopen', handler) + return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler) + }, []) + useEffect(() => { getMe() .then(setMe) @@ -134,6 +148,10 @@ export default function App() { } /> } /> } /> + {/* §14.5 / §14.6: cookie-consent companions to /philosophy. + Available to anonymous and authenticated viewers alike. */} + } /> + } /> {viewer && ( } /> )} @@ -174,10 +192,18 @@ export default function App() { setInboxOpen(false)} lastChangeTick={inboxTick} /> )} + ) } +function PolicyShell({ children }) { + // §14.5 / §14.6 policy pages reuse the chrome-pane shape so they + // render full-width without the catalog rail. The components inside + // carry their own back affordance per Philosophy.jsx's pattern. + return
{children}
+} + function PhilosophyWithSidebar({ viewer }) { // The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin) // all use the full app body — no catalog left pane, no propose modal. diff --git a/frontend/src/api.js b/frontend/src/api.js index 272e108..d910ef6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -532,6 +532,23 @@ export async function setQuietHours({ start, end, timezone } = {}) { })) } +// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5). +export async function getCookieConsent() { + return jsonOrThrow(await fetch('/api/users/me/cookie-consent')) +} + +export async function setCookieConsent({ analytics, other } = {}) { + return jsonOrThrow(await fetch('/api/users/me/cookie-consent', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + essential: true, + analytics: !!analytics, + other: !!other, + }), + })) +} + export async function muteUser(userId) { return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' })) } diff --git a/frontend/src/components/CookieConsentBanner.jsx b/frontend/src/components/CookieConsentBanner.jsx new file mode 100644 index 0000000..5587a84 --- /dev/null +++ b/frontend/src/components/CookieConsentBanner.jsx @@ -0,0 +1,164 @@ +// CookieConsentBanner.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5. +// +// A non-modal bottom-of-page banner that asks the user once which +// categories of cookies they accept. The framework's strictly-necessary +// cookies (session, signed payloads) are always on; the user can opt in +// or out of analytics (which gates the §13 SDK landing in v0.15.0) and +// "other" (third-party embeds, social widgets if a deployment adds any). +// +// Visible until the user makes a choice. Hides itself once the choice +// is recorded. The /settings/notifications "Privacy & cookies" tab +// surfaces the current choice and re-opens the banner via `forceOpen`. + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js' +import { getCookieConsent, setCookieConsent } from '../api.js' + +const CATEGORIES = [ + { + key: 'essential-only', + label: 'Essential only', + description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)', + flags: { analytics: false, other: false }, + }, + { + key: 'essential-analytics', + label: 'Essential + analytics', + description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.', + flags: { analytics: true, other: false }, + }, + { + key: 'essential-analytics-other', + label: 'Essential + analytics + other', + description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.', + flags: { analytics: true, other: true }, + }, +] + +function selectionKeyFor(consent) { + if (consent.analytics && consent.other) return 'essential-analytics-other' + if (consent.analytics && !consent.other) return 'essential-analytics' + return 'essential-only' +} + +export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) { + const [open, setOpen] = useState(() => forceOpen || !hasChosen()) + const [choice, setChoice] = useState(() => selectionKeyFor(getConsent())) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // When forceOpen flips (settings "Change" affordance), re-render the + // banner and pre-select the user's current choice. + useEffect(() => { + if (forceOpen) { + setOpen(true) + setChoice(selectionKeyFor(getConsent())) + } + }, [forceOpen]) + + // Server-side hydrate for authenticated viewers per the v0.13.0 + // precedence rule: a server row overrides local; absent server row, + // upload the local choice. + useEffect(() => { + if (!viewer?.user_id) return + let cancelled = false + getCookieConsent() + .then(record => { + if (cancelled) return + if (record.recorded_at) { + // Server is authoritative — adopt + hide the banner unless + // the settings page forced it open. + hydrateFromServer(record) + setChoice(selectionKeyFor(record)) + if (!forceOpen) setOpen(false) + } else if (hasChosen()) { + // Local has a choice the server doesn't know about yet — push. + const local = getConsent() + setCookieConsent({ analytics: local.analytics, other: local.other }) + .then(r => hydrateFromServer(r)) + .catch(() => {}) + } + }) + .catch(() => { + // Network or auth error — leave the local-only path in place. + }) + return () => { cancelled = true } + }, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps + + if (!open) return null + + async function save() { + setSaving(true) + setError(null) + const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0] + try { + setConsent(picked.flags) + if (viewer?.user_id) { + // Best-effort server persistence. A failure here doesn't + // invalidate the local choice; the banner still hides because + // the user expressed their preference. The server can catch up + // on the next sign-in via the hydrate path above. + try { + const r = await setCookieConsent(picked.flags) + hydrateFromServer(r) + } catch (e) { + setError(`Saved locally; server sync failed (${e.message}).`) + } + } + setOpen(false) + onClosed?.() + } finally { + setSaving(false) + } + } + + return ( +
+
+

Cookies & privacy

+

+ This site uses cookies. Essential cookies keep you signed in and + protect your submissions; analytics and other cookies are + optional. Choose what you allow — you can change this any time + from Settings → Privacy & cookies. +

+
+ Cookie categories + {CATEGORIES.map(c => ( + + ))} +
+

+ Cookies policy + · + Privacy policy +

+ {error &&

{error}

} +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/NotificationSettings.jsx b/frontend/src/components/NotificationSettings.jsx index d9f7067..4a20022 100644 --- a/frontend/src/components/NotificationSettings.jsx +++ b/frontend/src/components/NotificationSettings.jsx @@ -28,7 +28,9 @@ import { unmuteUser, muteUser, searchUsers, + getCookieConsent, } from '../api.js' +import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js' const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.' @@ -48,10 +50,67 @@ export default function NotificationSettings({ viewer }) { + ) } +// ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ──────────── + +function PrivacyCookiesSection() { + const [consent, setConsent] = useState(() => getConsent()) + + useEffect(() => { + // Pull the server-side row on mount; if it has a recorded_at the + // local snapshot is updated via hydrate. + getCookieConsent() + .then(record => { if (record.recorded_at) hydrateFromServer(record) }) + .catch(() => {}) + return onConsentChange(next => setConsent(next)) + }, []) + + function reopenBanner() { + // App.jsx listens for this event and bumps the forceOpen tick on + // . The banner pre-selects the current choice + // from the snapshot, so the user can revise rather than restart. + window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen')) + } + + const summary = (() => { + if (!consent.recorded_at) { + return 'No choice recorded — the consent banner is being shown to you.' + } + if (consent.analytics && consent.other) { + return 'Essential + analytics + other.' + } + if (consent.analytics) { + return 'Essential + analytics.' + } + return 'Essential only.' + })() + + return ( + +
+ Current choice: {summary} +
+ {consent.recorded_at && ( +

Recorded {consent.recorded_at}.

+ )} +
+ + Cookies policy + Privacy policy +
+
+ ) +} + // ── §15.4 email category toggles ─────────────────────────────────────────── function EmailPreferencesSection() { diff --git a/frontend/src/lib/consent.js b/frontend/src/lib/consent.js new file mode 100644 index 0000000..4daa612 --- /dev/null +++ b/frontend/src/lib/consent.js @@ -0,0 +1,154 @@ +// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5). +// +// The framework's analytics SDK gating (roadmap item #13, target v0.15.0) +// will read from this module. v0.13.0 ships the storage + the banner + +// the on-change pub/sub; no analytics SDK ships yet. +// +// Shape of a consent record: +// +// { essential: true, analytics: bool, other: bool, recorded_at: string | null } +// +// `essential` is always true at the API surface; it's included for +// symmetry. `recorded_at` is null when the user has not yet made a +// choice — the banner is shown until it's non-null. +// +// Precedence: +// - Anonymous viewer: localStorage is the only source. +// - Authenticated viewer: on sign-in, the server row (if any) overrides +// local; if the server has no row, the local choice is uploaded. +// +// The fan-out is intentionally tiny — three flags. The banner writes +// once; subscribers re-read on demand via `getConsent()` and can +// register `onConsentChange(cb)` to be notified of subsequent updates. +// +// IMPORTANT: don't import this from analytics SDKs that themselves +// set cookies on load. Read consent first, then conditionally `import()` +// the SDK module — that's the contract item #13 will follow. + +const LS_KEY = 'rfc-app.cookie-consent.v1' + +const DEFAULT = Object.freeze({ + essential: true, + analytics: false, + other: false, + recorded_at: null, +}) + +const listeners = new Set() + +function readLocal() { + try { + const raw = localStorage.getItem(LS_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return null + return normalize(parsed) + } catch { + return null + } +} + +function writeLocal(record) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(normalize(record))) + } catch { + // localStorage may be unavailable (private mode, disabled storage). + // In that case we behave as if no choice was ever made — the banner + // shows on every load. Acceptable per §14.5: the user can still + // refuse to consent on each visit. + } +} + +function normalize(record) { + return { + essential: true, + analytics: !!record.analytics, + other: !!record.other, + recorded_at: record.recorded_at || null, + } +} + +// In-memory snapshot. Initialised lazily on first read so the module +// import order doesn't matter; refreshed by `setConsent` and +// `hydrateFromServer`. +let _snapshot = null + +function snapshot() { + if (_snapshot == null) { + _snapshot = readLocal() || { ...DEFAULT } + } + return _snapshot +} + +function emit() { + for (const cb of listeners) { + try { cb(snapshot()) } catch {} + } +} + +/** Read the current consent record. Always returns a normalized object; + * `recorded_at: null` means the user has not yet chosen. */ +export function getConsent() { + return snapshot() +} + +/** True if the user has made a choice. The banner uses this to decide + * whether to render itself on load. */ +export function hasChosen() { + return snapshot().recorded_at != null +} + +/** Subscribe to consent updates. Returns an unsubscribe function. */ +export function onConsentChange(cb) { + listeners.add(cb) + return () => listeners.delete(cb) +} + +/** Write a new choice locally and emit. Returns the new snapshot. The + * server-side persistence path is handled separately by the banner / + * settings surface via the API client; this helper is for both anon + * and authenticated callers because localStorage is the always-on + * layer (the server row is a backup that survives sign-out). */ +export function setConsent({ analytics = false, other = false } = {}) { + const next = normalize({ + analytics, + other, + recorded_at: new Date().toISOString(), + }) + _snapshot = next + writeLocal(next) + emit() + return next +} + +/** Adopt a server-side record as authoritative. Called by the banner / + * settings surface after sign-in when the server returns a non-null + * recorded_at. Updates local + memory + emits to subscribers. */ +export function hydrateFromServer(record) { + if (!record || !record.recorded_at) return snapshot() + const next = normalize(record) + _snapshot = next + writeLocal(next) + emit() + return next +} + +/** Reset local state — used by the settings "Change" affordance to + * re-prompt the banner. Does not touch the server row; the user must + * re-confirm a choice and the banner uploads on save. */ +export function clearLocal() { + try { localStorage.removeItem(LS_KEY) } catch {} + _snapshot = { ...DEFAULT } + emit() + return _snapshot +} + +// Cross-tab sync: if another tab writes the key, mirror the change here. +// Wrapped in a guard so SSR / non-browser test contexts don't blow up. +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('storage', e => { + if (e.key !== LS_KEY) return + _snapshot = readLocal() || { ...DEFAULT } + emit() + }) +} diff --git a/frontend/src/pages/Cookies.jsx b/frontend/src/pages/Cookies.jsx new file mode 100644 index 0000000..7c680ba --- /dev/null +++ b/frontend/src/pages/Cookies.jsx @@ -0,0 +1,125 @@ +// Cookies.jsx — v0.13.0 / roadmap item #11 / SPEC §14.6. +// +// Lists the framework's cookies, by category, with each cookie's +// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked +// below the framework's stub list, same shape as the privacy page). +// +// Keeping the list in source makes the framework self-documenting: +// when a future framework release adds or removes a cookie, this page +// is the change-record. Item #13's analytics SDK will add its own row +// to the analytics-category list in v0.15.0. + +import { useNavigate, Link } from 'react-router-dom' + +const COOKIES = [ + { + name: 'rfc_session', + category: 'Essential', + purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.", + lifetime: 'Session (cleared on sign-out).', + }, + { + name: 'rfc-app.cookie-consent.v1', + category: 'Essential', + purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.', + lifetime: 'Until cleared.', + }, +] + +export default function Cookies() { + const navigate = useNavigate() + const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim() + const appName = import.meta.env.VITE_APP_NAME || 'this deployment' + + return ( +
+
+ + Cookies policy +
+
+

Cookies policy

+

+ What {appName} stores in your browser, by category. +

+ +

Categories

+
    +
  • + Essential — required for the app to keep + you signed in, protect submissions, and remember your + consent choice. Cannot be switched off (without these the + app cannot function). +
  • +
  • + Analytics — optional anonymous usage + telemetry. Off by default; opt-in via the consent banner. + As of v0.13.0 no analytics SDK ships; roadmap item #13 + (v0.15.0) adds one behind this gate. +
  • +
  • + Other — third-party embeds, social + widgets, or anything else the deployment chooses to enable. + Off by default; opt-in via the consent banner. The + framework ships no such cookies by default. +
  • +
+ +

Current cookies set by the framework

+ + + + + + + + + + + {COOKIES.map(c => ( + + + + + + + ))} + +
NameCategoryPurposeLifetime
{c.name}{c.category}{c.purpose}{c.lifetime}
+ +

Manage your choice

+

+ Change your consent any time from{' '} + + Settings → Privacy & cookies + . The "Change" affordance re-opens the consent banner + with your current selection pre-loaded. +

+ + {deploymentUrl ? ( + <> +

Deployment-specific cookies

+

+ This deployment may add additional cookies on top of the + framework's. See the full deployment policy at: +

+

+ + {deploymentUrl} + +

+ + ) : null} + +

+ See also the privacy policy. +

+
+
+ ) +} diff --git a/frontend/src/pages/Privacy.jsx b/frontend/src/pages/Privacy.jsx new file mode 100644 index 0000000..8336df5 --- /dev/null +++ b/frontend/src/pages/Privacy.jsx @@ -0,0 +1,118 @@ +// Privacy.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5. +// +// The framework's default privacy policy page. Reachable by anonymous +// and authenticated viewers alike at `/privacy`. The text below is a +// minimal stub that describes the framework's stance; deployments are +// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var. +// +// When `VITE_PRIVACY_POLICY_URL` is set: +// - http(s) URL → the page renders the framework's stub above a +// "Read the full deployment policy" link to the configured URL. +// We don't iframe-embed third-party policy hosts because their +// Content-Security-Policy frequently refuses framing; the link is +// the predictable affordance. +// +// The framework's stub is intentionally short — the rules that matter +// to a user are: (1) what categories of cookies the app sets, (2) how +// to change consent, (3) where to reach the deployment operator with a +// complaint. Each deployment's content repo can carry a fuller version. + +import { useNavigate, Link } from 'react-router-dom' + +export default function Privacy() { + const navigate = useNavigate() + const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim() + const appName = import.meta.env.VITE_APP_NAME || 'this deployment' + + return ( +
+
+ + Privacy policy +
+
+

Privacy policy

+

+ What {appName} stores, why, and how to control it. +

+ +

What we store

+

+ {appName} runs on the Wiggleverse RFC framework. The framework + stores the identity you sign in with (your Gitea login, + display name, email, and avatar URL), the proposals and edits + you author, the discussion threads you participate in, and + your notification preferences. Authoring is public by design — + this is a framework for public-async RFC work, and threads, + changes, and PRs are visible to anyone who reaches the + deployment. Settings (notification toggles, quiet hours, mute + list, cookie consent) are private to your account. +

+ +

Cookies

+

+ The app sets a small set of cookies. The full list is on the{' '} + cookies policy page. You can choose + which categories you allow from the consent banner shown on + your first visit or from + Settings → Privacy & cookies any time + afterwards. +

+ +

Analytics

+

+ The framework supports an optional anonymous analytics layer + gated behind your consent choice. As of v0.13.0 no analytics + SDK ships in the framework; deployments that enable analytics + do so via a later framework version (roadmap item #13). The + consent toggle exists today so the gate is already in place + when the SDK lands. +

+ +

Your data, your control

+
    +
  • Revoke cookie consent any time from settings.
  • +
  • + Edit notification preferences — including the global email + opt-out — from{' '} + notification settings. +
  • +
  • + Your authored content (proposals, threads, edits) is public + and not retractable from the meta-repo's Git history. If you + need a redaction, reach the deployment operator directly. +
  • +
+ + {deploymentUrl ? ( + <> +

Deployment-specific policy

+

+ This deployment may layer additional policy on top of the + framework's defaults. Read the full deployment policy at: +

+

+ + {deploymentUrl} + +

+ + ) : ( + <> +

Deployment contact

+

+ For deployment-specific privacy questions — data subject + requests, redaction requests, complaints — contact the + operator of {appName}. +

+ + )} +
+
+ ) +}