// /settings/notifications — the §15.4 / §15.5 / §15.6 / §15.8 surface.
//
// Topic 13 settled the schema and the per-category rules; Slice 6 wired
// the endpoints; Slice 7 lands the surface where a contributor finds
// the per-category email toggles, the digest cadence dropdown, the
// quiet-hours editor, the watches overview, and the per-user mute
// list. The §15.4 email footer's "Manage all preferences" link points
// at this route.
//
// Each sub-section is intentionally thin — the rules are settled in
// §15; this page renders them. The one piece of voice the spec
// commits to and the surface inherits: the `email_watched_churn`
// toggle renders as permanently disabled with the §15.4 refusal
// tooltip ("Per-commit and per-message email is intentionally not
// offered. The digest aggregates this activity weekly."). Naming the
// refusal is the spec's commitment; silently omitting the toggle
// would let the contract drift.
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getNotificationPreferences,
setNotificationPreferences,
getQuietHours,
setQuietHours,
listWatches,
setWatch,
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
getMe,
setPasscode,
clearPasscode,
listMyDevices,
revokeMyDevice,
revokeAllMyDevices,
} 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.'
export default function NotificationSettings({ viewer }) {
return (
Notification settings
Which signals reach you, how, and when. The rules live in §15;
this page is where you tune them.
)
}
// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
//
// Lists the user's active device-trust rows and lets them revoke any
// or all. A revoke marks the row dead server-side; the matching
// device's next visit will be refused and the cookie cleared. The
// surface intentionally does not single out the row whose cookie the
// current request carries — every row reads identically, so the user
// can revoke "this device" alongside any other from a single page.
function DevicesSection() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
refresh()
}, [])
async function refresh() {
try {
const { items } = await listMyDevices()
setDevices(items || [])
setError(null)
} catch (e) {
setError(e.message || 'Could not load trusted devices.')
}
}
async function onRevoke(deviceId) {
setBusy(true)
try {
await revokeMyDevice(deviceId)
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke device.')
} finally {
setBusy(false)
}
}
async function onRevokeAll() {
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
return
}
setBusy(true)
try {
await revokeAllMyDevices()
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke devices.')
} finally {
setBusy(false)
}
}
return (
{devices === null &&
Loading…
}
{devices !== null && devices.length === 0 && (
No trusted devices. Sign in and check “Trust this device for 30 days”
to add the device you're on now.
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
))}
>
)}
{error &&
{error}
}
)
}
function formatStamp(stamp) {
// The server emits SQLite `datetime('now')` strings (UTC, no
// timezone marker). Parse defensively; fall back to the raw stamp
// if Date can't make sense of it.
if (!stamp) return '—'
const d = new Date(stamp.replace(' ', 'T') + 'Z')
if (Number.isNaN(d.getTime())) return stamp
return d.toLocaleString()
}
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
function SignInSection() {
// Source of truth for `has_passcode` and `passcode_set_at` is the
// /api/auth/me payload (v0.10.0 added both fields). We re-read after
// every mutation so the surface reflects what just landed.
const [me, setMe] = useState(null)
const [error, setError] = useState(null)
const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [savedNote, setSavedNote] = useState('')
useEffect(() => {
getMe()
.then(payload => setMe(payload.user || null))
.catch(e => setError(e.message))
}, [])
async function refresh() {
const payload = await getMe()
setMe(payload.user || null)
}
async function save(e) {
if (e) e.preventDefault()
const pc = draft.trim()
if (pc.length < 4) {
setError('Passcode must be at least 4 characters.')
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await setPasscode(pc)
setDraft('')
setMode('idle')
setSavedNote('Passcode saved.')
await refresh()
} catch (err) {
// 422 carries the validation message verbatim (denylist /
// length); surface it as-is so the user knows what to change.
setError(err.message || 'Could not save passcode. Try a different one.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
async function remove() {
if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await clearPasscode()
setSavedNote('Passcode removed.')
await refresh()
} catch (err) {
setError(err.message || 'Could not remove passcode.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
if (!me) return
const hasPasscode = !!me.has_passcode
return (
Passcode:{' '}
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
}
)
}
// ── §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 (
A hard bounce or complaint from your mailbox flipped the global
email opt-out. No email will be sent until you contact an admin
to clear the flag, even if individual categories are enabled.