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:
Ben Stull
2026-05-28 02:24:38 -07:00
parent 8aa65014b4
commit 8f3a8ec33a
13 changed files with 1794 additions and 62 deletions
+43
View File
@@ -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'))
}
+255 -23
View File
@@ -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 (420 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="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() {