8aa65014b4
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>
165 lines
6.3 KiB
React
165 lines
6.3 KiB
React
// 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>
|
|
)
|
|
}
|