Release 0.10.0: user-set passcodes (OTC stays as fallback)
After a successful OTC sign-in, a contributor can set a passcode (4-20 characters, bcrypt-hashed) and use email + passcode for subsequent sign-ins. OTC remains the structural fallback: five consecutive failed verifies lock the passcode path for 15 minutes (HTTP 423), and a forgotten passcode is recovered by requesting a fresh code. Migration 015_passcode.sql adds four nullable columns to the users table; existing rows pass through as OTC-only and can opt into a passcode from a new Sign-in tab in /settings/notifications. The /login surface is extended to a five-step flow (email → either passcode or OTC code → optional post-OTC passcode offer → optional set-passcode). SPEC corrections per §19.3 rule 2: §6 names the three auth paths, §14.1 documents the stepped login flow, §17 lists the four new /auth/passcode/* endpoints, §19.2 surfaces four new candidates and refreshes the cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,49 @@ export async function verifyOtc(email, code) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
|
||||
//
|
||||
// After a successful OTC sign-in, a contributor may set a passcode and
|
||||
// use email + passcode for subsequent sign-ins. OTC remains the
|
||||
// forgot-passcode fallback — 5 consecutive verify failures locks the
|
||||
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
|
||||
|
||||
export async function checkPasscode(email) {
|
||||
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
|
||||
// Login.jsx flow can decide whether to render a passcode input or
|
||||
// fall back to OTC. We URL-encode the email so addresses with '+'
|
||||
// round-trip cleanly.
|
||||
const params = new URLSearchParams({ email })
|
||||
const res = await fetch(`/auth/passcode/check?${params}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyPasscode(email, passcode) {
|
||||
const res = await fetch('/auth/passcode/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function setPasscode(passcode) {
|
||||
// Requires an active session — the server returns 401 if not signed
|
||||
// in. The signed-in user is the implicit subject; the body carries
|
||||
// only the new passcode.
|
||||
const res = await fetch('/auth/passcode/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function clearPasscode() {
|
||||
const res = await fetch('/auth/passcode', { method: 'DELETE' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
// Login.jsx — v0.7.0's primary sign-in surface (§6.2).
|
||||
// Login.jsx — v0.7.0's email + OTC sign-in surface (§6.2), extended
|
||||
// in v0.10.0 with passcode sign-in (roadmap item #8).
|
||||
//
|
||||
// Two-step:
|
||||
// 1. Enter email → POST /auth/otc/request → on 200, advance.
|
||||
// On 429 (rate-limit), surface a "wait a moment" hint and keep
|
||||
// the user on step 1.
|
||||
// 2. Enter the six-digit code from the email → POST /auth/otc/verify
|
||||
// → on 200, redirect to the post-login landing. Cmd/Ctrl+Enter
|
||||
// on the code field is the keyboard shortcut.
|
||||
// Three-to-five-step flow:
|
||||
// 1. Enter email → GET /auth/passcode/check.
|
||||
// * If `has_passcode`: advance to step 'passcode'.
|
||||
// * Otherwise: POST /auth/otc/request, advance to step 'code'.
|
||||
// 2a. Step 'passcode': enter passcode → POST /auth/passcode/verify.
|
||||
// * On 200: redirect to /.
|
||||
// * On 423: passcode is locked (5 consecutive failures); the
|
||||
// UI auto-falls back to OTC by requesting a fresh code.
|
||||
// * On 400: wrong passcode; the user can retry or click
|
||||
// "Use a code instead" to fall back to OTC manually.
|
||||
// 2b. Step 'code' (the v0.7.0 path): enter the six-digit code →
|
||||
// POST /auth/otc/verify → on 200, the server has signed in the
|
||||
// user. If the user has no passcode set, we show step
|
||||
// 'offer-passcode' inviting them to set one for faster sign-in
|
||||
// next time. Dismiss skips to /; "Set passcode" advances to
|
||||
// step 'set-passcode'.
|
||||
// 3. Step 'set-passcode': enter a passcode → POST /auth/passcode/set
|
||||
// → redirect to /. The user can also "Skip for now".
|
||||
//
|
||||
// Server-side, /auth/otc/request always returns 202 for an unrecognized
|
||||
// email (so the allowlist gate doesn't leak), so this surface never
|
||||
// distinguishes "we couldn't reach you" from "we don't know you" —
|
||||
// it just advances to step 2. If a user is genuinely blocked, the
|
||||
// code never arrives.
|
||||
// distinguishes "we couldn't reach you" from "we don't know you". The
|
||||
// check endpoint also returns `has_passcode: false` for an unknown
|
||||
// email — so an unknown email always lands in the OTC path, no
|
||||
// account-enumeration signal.
|
||||
//
|
||||
// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback
|
||||
// during the v0.7.0 migration; we surface a "Sign in with Gitea" link
|
||||
@@ -21,21 +34,36 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { requestOtc, verifyOtc } from '../api'
|
||||
import {
|
||||
requestOtc,
|
||||
verifyOtc,
|
||||
checkPasscode,
|
||||
verifyPasscode,
|
||||
setPasscode as apiSetPasscode,
|
||||
} from '../api'
|
||||
|
||||
export default function Login() {
|
||||
// Steps: 'email' → 'passcode' or 'code' → (after OTC verify) optional
|
||||
// 'offer-passcode' → optional 'set-passcode'. The latter two only
|
||||
// appear on the OTC path for accounts that don't yet have a passcode.
|
||||
const [step, setStep] = useState('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [passcode, setPasscode] = useState('')
|
||||
const [newPasscode, setNewPasscode] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const emailRef = useRef(null)
|
||||
const codeRef = useRef(null)
|
||||
const passcodeRef = useRef(null)
|
||||
const newPasscodeRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'email') emailRef.current?.focus()
|
||||
else codeRef.current?.focus()
|
||||
else if (step === 'code') codeRef.current?.focus()
|
||||
else if (step === 'passcode') passcodeRef.current?.focus()
|
||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||
}, [step])
|
||||
|
||||
async function submitEmail(e) {
|
||||
@@ -47,20 +75,68 @@ export default function Login() {
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await requestOtc(email.trim())
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
const { has_passcode } = await checkPasscode(email.trim())
|
||||
if (has_passcode) {
|
||||
setStep('passcode')
|
||||
setStatus('')
|
||||
} else {
|
||||
await requestOtc(email.trim())
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status === 429) {
|
||||
setStatus('Slow down — wait a minute before requesting another code.')
|
||||
} else {
|
||||
setStatus(err.message || 'Could not request a code. Try again.')
|
||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!passcode.trim()) {
|
||||
setStatus('Enter your passcode.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyPasscode(email.trim(), passcode.trim())
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
if (err.status === 423) {
|
||||
// Lockout — auto-fall back to OTC. The OTC request endpoint
|
||||
// is independent of the passcode lockout, so this lands a
|
||||
// fresh code in the user's inbox immediately.
|
||||
setPasscode('')
|
||||
try {
|
||||
await requestOtc(email.trim())
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||
)
|
||||
} catch (e2) {
|
||||
if (e2.status === 429) {
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
||||
)
|
||||
} else {
|
||||
setStatus(
|
||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStatus('Wrong passcode. Try again, or use a one-time code instead.')
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!code.trim() || code.trim().length !== 6) {
|
||||
@@ -71,16 +147,43 @@ export default function Login() {
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyOtc(email.trim(), code.trim())
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. We
|
||||
// navigate to "/" via a hard load so any cached "anonymous"
|
||||
// view state in memory is dropped cleanly.
|
||||
window.location.assign('/')
|
||||
// OTC verified. If the user has no passcode, offer to set one
|
||||
// before redirecting. We re-read `has_passcode` from the server
|
||||
// rather than caching the step-1 result because the user could
|
||||
// have set a passcode in another tab between then and now.
|
||||
const { has_passcode } = await checkPasscode(email.trim())
|
||||
if (has_passcode) {
|
||||
window.location.assign('/')
|
||||
} else {
|
||||
setStep('offer-passcode')
|
||||
setBusy(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = newPasscode.trim()
|
||||
if (pc.length < 4) {
|
||||
setStatus('Passcode must be at least 4 characters.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await apiSetPasscode(pc)
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
// 422 carries the validation message verbatim (denylist /
|
||||
// length); surface it as-is so the user knows what to change.
|
||||
setStatus(err.message || 'Could not set passcode. Try a different one.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onCodeKey(e) {
|
||||
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
@@ -88,12 +191,44 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPasscodeKey(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitPasscode(e)
|
||||
}
|
||||
}
|
||||
|
||||
function backToEmail() {
|
||||
setStep('email')
|
||||
setCode('')
|
||||
setPasscode('')
|
||||
setStatus('')
|
||||
}
|
||||
|
||||
async function fallbackToOtc() {
|
||||
// Manual "Use a code instead" from the passcode step. Same shape
|
||||
// as the email-step OTC dispatch.
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await requestOtc(email.trim())
|
||||
setPasscode('')
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
} catch (err) {
|
||||
if (err.status === 429) {
|
||||
setStatus('Slow down — wait a minute before requesting another code.')
|
||||
} else {
|
||||
setStatus(err.message || 'Could not request a code. Try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function skipPasscodeOffer() {
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="otc-login">
|
||||
<div className="otc-login-inner">
|
||||
@@ -101,7 +236,8 @@ export default function Login() {
|
||||
{step === 'email' && (
|
||||
<form onSubmit={submitEmail}>
|
||||
<p className="otc-hint">
|
||||
Enter your email. We'll send you a one-time code.
|
||||
Enter your email. If you've set a passcode, you'll enter that
|
||||
next; otherwise we'll send a one-time code.
|
||||
</p>
|
||||
<input
|
||||
ref={emailRef}
|
||||
@@ -114,10 +250,49 @@ export default function Login() {
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="submit" disabled={busy || !email.trim()}>
|
||||
{busy ? 'Sending…' : 'Send code'}
|
||||
{busy ? 'Checking…' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{step === 'passcode' && (
|
||||
<form onSubmit={submitPasscode}>
|
||||
<p className="otc-hint">
|
||||
Enter the passcode for <strong>{email}</strong>.
|
||||
</p>
|
||||
<input
|
||||
ref={passcodeRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={passcode}
|
||||
onChange={e => setPasscode(e.target.value)}
|
||||
onKeyDown={onPasscodeKey}
|
||||
placeholder="Your passcode"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="otc-actions">
|
||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={fallbackToOtc}
|
||||
disabled={busy}
|
||||
>
|
||||
Use a code instead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={backToEmail}
|
||||
disabled={busy}
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{step === 'code' && (
|
||||
<form onSubmit={submitCode}>
|
||||
<p className="otc-hint">
|
||||
@@ -155,6 +330,63 @@ export default function Login() {
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{step === 'offer-passcode' && (
|
||||
<div className="otc-offer-passcode">
|
||||
<p className="otc-hint">
|
||||
You're signed in. Want to set a passcode for faster sign-in
|
||||
next time? You can always use a one-time code instead — and
|
||||
you can change or remove the passcode from your settings.
|
||||
</p>
|
||||
<div className="otc-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('set-passcode'); setStatus('') }}
|
||||
>
|
||||
Set a passcode
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={skipPasscodeOffer}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 'set-passcode' && (
|
||||
<form onSubmit={submitNewPasscode}>
|
||||
<p className="otc-hint">
|
||||
Pick a passcode (4–20 characters). You'll use it with your
|
||||
email to sign in next time.
|
||||
</p>
|
||||
<input
|
||||
ref={newPasscodeRef}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPasscode}
|
||||
onChange={e => setNewPasscode(e.target.value)}
|
||||
placeholder="New passcode"
|
||||
required
|
||||
disabled={busy}
|
||||
minLength={4}
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="otc-actions">
|
||||
<button type="submit" disabled={busy || newPasscode.trim().length < 4}>
|
||||
{busy ? 'Saving…' : 'Save passcode'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={skipPasscodeOffer}
|
||||
disabled={busy}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{status && <p className="otc-status">{status}</p>}
|
||||
<p className="otc-fallback">
|
||||
<Link to="/philosophy">Read the philosophy →</Link>
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
muteUser,
|
||||
searchUsers,
|
||||
getCookieConsent,
|
||||
getMe,
|
||||
setPasscode,
|
||||
clearPasscode,
|
||||
} from '../api.js'
|
||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||
|
||||
@@ -50,11 +53,167 @@ export default function NotificationSettings({ viewer }) {
|
||||
<QuietHoursSection />
|
||||
<WatchesSection />
|
||||
<MutesSection viewer={viewer} />
|
||||
<SignInSection />
|
||||
<PrivacyCookiesSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §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="4–20 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() {
|
||||
|
||||
Reference in New Issue
Block a user