Files
rfc-app/frontend/src/components/NotificationSettings.jsx
T
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00

848 lines
27 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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 { entryPath, useProjectId } from '../lib/entryPaths'
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 (
<div className="settings-page">
<header className="settings-header">
<h1>Notification settings</h1>
<p className="settings-sub">
Which signals reach you, how, and when. The rules live in §15;
this page is where you tune them.
</p>
</header>
<EmailPreferencesSection />
<DigestCadenceSection />
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<SignInSection />
<DevicesSection />
<PrivacyCookiesSection />
</div>
)
}
// ── 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 (
<SectionShell
title="Trusted devices"
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
>
{devices === null && <p className="settings-note">Loading</p>}
{devices !== null && devices.length === 0 && (
<p className="device-empty">
No trusted devices. Sign in and check Trust this device for 30 days
to add the device you're on now.
</p>
)}
{devices !== null && devices.length > 0 && (
<>
<ul className="device-list">
{devices.map(d => (
<li key={d.id} className="device-list-item">
<div className="device-meta">
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
<div className="device-stamps">
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
</div>
</div>
<button
type="button"
onClick={() => onRevoke(d.id)}
disabled={busy}
title="Revoke trust on this device"
>
Revoke
</button>
</li>
))}
</ul>
<button
type="button"
className="device-revoke-all"
onClick={onRevokeAll}
disabled={busy}
>
Revoke all devices
</button>
</>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
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 <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
const hasPasscode = !!me.has_passcode
return (
<SectionShell
title="Sign-in"
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
>
<div className="settings-row">
<span className="settings-note">
<strong>Passcode:</strong>{' '}
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
</span>
</div>
{hasPasscode && me.passcode_set_at && (
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
)}
{mode === 'idle' && (
<div className="settings-row">
{hasPasscode ? (
<>
<button
className="btn-primary"
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
disabled={busy}
>
Change passcode
</button>
<button
className="btn-link-muted"
onClick={remove}
disabled={busy}
>
Remove passcode
</button>
</>
) : (
<button
className="btn-primary"
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
disabled={busy}
>
Set passcode
</button>
)}
</div>
)}
{(mode === 'set' || mode === 'change') && (
<form className="settings-row" onSubmit={save}>
<label>
{mode === 'change' ? 'New passcode' : 'Passcode'}
<input
type="password"
autoComplete="new-password"
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="420 characters"
minLength={4}
maxLength={20}
required
disabled={busy}
/>
</label>
<button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
{busy ? 'Saving…' : 'Save'}
</button>
<button
type="button"
className="btn-link-muted"
onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
disabled={busy}
>
Cancel
</button>
</form>
)}
{savedNote && <p className="settings-note">{savedNote}</p>}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// ── §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
// <CookieConsentBanner>. 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 (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// ── §15.4 email category toggles ───────────────────────────────────────────
function EmailPreferencesSection() {
const [prefs, setPrefs] = useState(null)
const [saving, setSaving] = useState(false)
const [savedNote, setSavedNote] = useState('')
const [error, setError] = useState(null)
useEffect(() => {
getNotificationPreferences().then(setPrefs).catch(e => setError(e.message))
}, [])
async function update(field, value) {
setSaving(true)
setSavedNote('')
try {
await setNotificationPreferences({ [field]: value })
setPrefs(p => ({ ...p, [field]: value }))
setSavedNote('Saved.')
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
setTimeout(() => setSavedNote(''), 1500)
}
}
if (!prefs) return <SectionShell title="Email" subtitle={error || 'Loading…'} />
return (
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
<Toggle
label="Personal direct"
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
checked={!!prefs.email_personal_direct}
onChange={v => update('email_personal_direct', v)}
disabled={saving}
/>
<Toggle
label="Watched structural"
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
checked={!!prefs.email_watched_structural}
onChange={v => update('email_watched_structural', v)}
disabled={saving}
/>
<Toggle
label="Admin actionable"
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
checked={!!prefs.email_admin_actionable}
onChange={v => update('email_admin_actionable', v)}
disabled={saving}
/>
<Toggle
label="Watched churn (per-commit, per-message)"
description={CHURN_REFUSAL}
checked={false}
disabled
title={CHURN_REFUSAL}
/>
{!!prefs.email_opt_out_all && (
<p className="settings-note warning">
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.
</p>
)}
<p className="settings-note">{savedNote}</p>
</SectionShell>
)
}
// ── §15.5 digest cadence ───────────────────────────────────────────────────
function DigestCadenceSection() {
const [cadence, setCadence] = useState(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly'))
}, [])
async function update(value) {
setSaving(true)
try {
await setNotificationPreferences({ digest_cadence: value })
setCadence(value)
} finally {
setSaving(false)
}
}
return (
<SectionShell
title="Digest cadence"
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
>
<div className="settings-row">
<select
value={cadence ?? 'weekly'}
onChange={e => update(e.target.value)}
disabled={saving || cadence == null}
>
<option value="off">Off never send a digest</option>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</div>
</SectionShell>
)
}
// ── §15.8 quiet hours ──────────────────────────────────────────────────────
function QuietHoursSection() {
const [data, setData] = useState(null)
const [draft, setDraft] = useState({ start: '', end: '', timezone: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// The browser ships an IANA tz list per §15.8 — preferable to a
// free-text field, since the API validates the trio.
const timezones = useMemo(() => {
try {
return Intl.supportedValuesOf('timeZone')
} catch {
return ['UTC']
}
}, [])
useEffect(() => {
getQuietHours().then(q => {
setData(q)
setDraft({
start: q.start || '',
end: q.end || '',
timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
})
})
}, [])
async function save() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone })
setData({ start: draft.start, end: draft.end, timezone: draft.timezone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function clear() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: null, end: null, timezone: null })
setData({ start: null, end: null, timezone: null })
setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
if (!data) return <SectionShell title="Quiet hours" subtitle="Loading…" />
const isSet = !!(data.start && data.end && data.timezone)
return (
<SectionShell
title="Quiet hours"
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
>
<div className="settings-row quiet-hours-row">
<label>
Start
<input
type="time"
value={draft.start}
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
/>
</label>
<label>
End
<input
type="time"
value={draft.end}
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
/>
</label>
<label>
Timezone
<select
value={draft.timezone}
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
>
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</label>
</div>
<div className="settings-row">
<button className="btn-primary" disabled={saving} onClick={save}>
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
</button>
{isSet && (
<button className="btn-link-muted" disabled={saving} onClick={clear}>
Clear
</button>
)}
</div>
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// ── §15.6 watches overview ─────────────────────────────────────────────────
function WatchesSection() {
const [watches, setWatches] = useState(null)
const [updating, setUpdating] = useState({})
const pid = useProjectId()
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
async function update(slug, state) {
setUpdating(u => ({ ...u, [slug]: true }))
try {
const r = await setWatch(slug, state)
setWatches(prev => prev.map(w => w.rfc_slug === slug
? { ...w, state: r.state, set_by: 'explicit' }
: w
))
} finally {
setUpdating(u => ({ ...u, [slug]: false }))
}
}
if (!watches) return <SectionShell title="Watches" subtitle="Loading…" />
return (
<SectionShell
title="Watches"
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
>
{watches.length === 0 && (
<p className="muted">No watches yet. Open an RFC, propose, or join a thread auto-watch will set one for you.</p>
)}
{watches.length > 0 && (
<table className="settings-table">
<thead>
<tr>
<th>RFC</th>
<th>State</th>
<th>Set by</th>
<th>Last participation</th>
</tr>
</thead>
<tbody>
{watches.map(w => (
<tr key={w.rfc_slug}>
<td>
<Link to={entryPath(pid, w.rfc_slug)}>{w.rfc_title || w.rfc_slug}</Link>
</td>
<td>
<select
value={w.state}
onChange={e => update(w.rfc_slug, e.target.value)}
disabled={!!updating[w.rfc_slug]}
>
<option value="watching">Watching</option>
<option value="following">Following</option>
<option value="muted">Muted</option>
</select>
</td>
<td>
<span className={`set-by set-by-${w.set_by}`}>
{w.set_by === 'explicit' ? 'You' : 'Auto'}
</span>
</td>
<td className="muted">
{w.last_participation_at || '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</SectionShell>
)
}
// ── §15.8 per-user mute list + typeahead add ───────────────────────────────
function MutesSection({ viewer }) {
const [mutes, setMutes] = useState(null)
const [error, setError] = useState(null)
async function refresh() {
try {
const r = await listMutes()
setMutes(r.items || [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function remove(userId) {
await unmuteUser(userId)
setMutes(prev => prev.filter(m => m.muted_user_id !== userId))
}
return (
<SectionShell
title="Muted users"
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
>
{viewer.role === 'owner' || viewer.role === 'admin' ? (
<p className="muted">
Owners and admins cannot mute notifications the role requires
receiving signals from everyone. (§15.8)
</p>
) : (
<MuteTypeahead onMuted={refresh} />
)}
{mutes && mutes.length === 0 && (
<p className="muted">No active mutes.</p>
)}
{mutes && mutes.length > 0 && (
<ul className="mutes-list">
{mutes.map(m => (
<li key={m.muted_user_id} className="mutes-row">
<span className="mute-handle">@{m.gitea_login}</span>
<span className="mute-name muted">{m.display_name}</span>
<span className="mute-when muted">since {m.muted_at}</span>
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
Unmute
</button>
</li>
))}
</ul>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// The mute-list read endpoint isn't a separate route in the API today
// (§17 names add/delete only); we read the join here against /api/users/me
// via a tiny dedicated endpoint that mirrors the watches shape. For
// Slice 7's v1 surface, we compute the list client-side from a server
// endpoint that returns the joined rows — added in api_admin.py's
// neighborhood for proximity.
async function listMutes() {
const res = await fetch('/api/users/me/notification-mutes')
if (!res.ok) {
const detail = await res.text()
throw new Error(detail || `HTTP ${res.status}`)
}
return res.json()
}
function MuteTypeahead({ onMuted }) {
const [q, setQ] = useState('')
const [results, setResults] = useState([])
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [hint, setHint] = useState('')
useEffect(() => {
const t = setTimeout(() => {
searchUsers(q).then(r => setResults(r.items || []))
}, 120)
return () => clearTimeout(t)
}, [q])
async function mute(user) {
setBusy(true)
setHint('')
try {
await muteUser(user.id)
setQ('')
setOpen(false)
onMuted?.()
} catch (e) {
setHint(e.message)
} finally {
setBusy(false)
}
}
return (
<div className="mute-typeahead">
<input
type="text"
placeholder="Mute a user by login or name…"
value={q}
onChange={e => { setQ(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
disabled={busy}
/>
{open && results.length > 0 && (
<ul className="mute-typeahead-results">
{results.map(r => (
<li key={r.id}>
<button onClick={() => mute(r)} disabled={busy}>
<span className="mute-handle">@{r.gitea_login}</span>
<span className="muted">{r.display_name}</span>
{(r.role === 'owner' || r.role === 'admin') && (
<span className="muted">{r.role}</span>
)}
</button>
</li>
))}
</ul>
)}
{hint && <p className="settings-note warning">{hint}</p>}
</div>
)
}
// ── Small layout primitives ────────────────────────────────────────────────
function SectionShell({ title, subtitle, children }) {
return (
<section className="settings-section">
<header>
<h2>{title}</h2>
{subtitle && <p className="settings-sub">{subtitle}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
)
}
function Toggle({ label, description, checked, onChange, disabled, title }) {
return (
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
<input
type="checkbox"
checked={!!checked}
onChange={e => onChange?.(e.target.checked)}
disabled={disabled}
/>
<span className="toggle-text">
<span className="toggle-label">{label}</span>
{description && <span className="toggle-desc">{description}</span>}
</span>
</label>
)
}