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:
@@ -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