Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
|
||||
<div className="cookie-consent-body">
|
||||
<h2 className="cookie-consent-title">Cookies & privacy</h2>
|
||||
<p className="cookie-consent-intro">
|
||||
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 <Link to="/settings/notifications">Settings → Privacy & cookies</Link>.
|
||||
</p>
|
||||
<fieldset className="cookie-consent-choices">
|
||||
<legend className="visually-hidden">Cookie categories</legend>
|
||||
{CATEGORIES.map(c => (
|
||||
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="cookie-consent-choice"
|
||||
value={c.key}
|
||||
checked={choice === c.key}
|
||||
onChange={() => setChoice(c.key)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="cookie-consent-choice-text">
|
||||
<span className="cookie-consent-choice-label">{c.label}</span>
|
||||
<span className="cookie-consent-choice-desc">{c.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<p className="cookie-consent-links">
|
||||
<Link to="/cookies">Cookies policy</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<Link to="/privacy">Privacy policy</Link>
|
||||
</p>
|
||||
{error && <p className="cookie-consent-error">{error}</p>}
|
||||
<div className="cookie-consent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save choice'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user