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 ca8ba69acb
commit 55beba5c0a
13 changed files with 1967 additions and 191 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.8.0",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.8.0",
"version": "0.10.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.8.0",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",
+43
View File
@@ -65,6 +65,49 @@ export async function submitBetaRequest({ first_name, last_name, beta_request_re
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'))
}
+343 -54
View File
@@ -1,43 +1,90 @@
// Login.jsx v0.7.0's primary sign-in surface (§6.2), extended by
// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile
// capture step.
// Login.jsx the composed sign-in surface (§6.2) after the v0.10.0
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
// established the email + OTC scaffolding both releases extended.
//
// Three-step (the third is conditional):
// 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, the response body carries `needs_profile`:
// * needs_profile=false (returning user, OAuth-era grandfather,
// or already-captured pending user): redirect to "/".
// * needs_profile=true (fresh OTC sign-in, no profile fields
// yet): advance to step 3.
// Cmd/Ctrl+Enter on the code field is the keyboard shortcut.
// 3. First name, last name, and "why I should be included in the
// beta" POST /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays `permission_state='pending'`
// until an admin grants access.
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
//
// Server-side, /auth/otc/request returns 202 uniformly so abuse paths
// (e.g. distributed allowlist-probing) don't leak the recognized-email
// set. This surface never distinguishes "we couldn't reach you" from
// "we don't know you" it just advances to step 2. If a request was
// rate-limited, the user sees a 429 hint and stays on step 1.
// 1. 'email' Enter email GET /auth/passcode/check.
// * has_passcode=true step 'passcode'.
// * has_passcode=false POST /auth/otc/request,
// step 'code'.
// 429 on either dispatch surfaces a "wait a
// moment" hint and keeps the user on step 1.
//
// The legacy Gitea OAuth callback remains at /auth/login /auth/callback
// during the migration; we surface a "Sign in with Gitea" link as a
// fallback in the footer so users with active OAuth sessions or older
// invite emails still have a path.
// 2a. 'passcode' Enter passcode POST /auth/passcode/verify.
// * 200 redirect to "/".
// * 423 (lockout, 5 consecutive failures)
// auto-fall back to OTC by requesting a fresh
// code and advancing to step 'code'.
// * 400 wrong passcode; user can retry or
// click "Use a code instead" to fall back
// manually.
//
// 2b. 'code' Enter the six-digit code POST /auth/otc/verify.
// On 200, fetch /api/auth/me and branch:
// * needs_profile === true 'capture-profile'
// * has_passcode === false 'offer-passcode'
// * otherwise redirect to "/".
// needs_profile WINS over has_passcode a
// pending user goes through the §6.1 capture
// flow first; setting a passcode while waiting
// for admin grant gains them nothing.
// Cmd/Ctrl+Enter on the code field is the
// keyboard shortcut.
//
// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
// I should be included in the beta" POST
// /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays
// permission_state='pending' until an admin
// grants access; they can set a passcode later
// from settings, or on a future sign-in once
// granted.
//
// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
// next time?" Yes 'set-passcode'. Skip "/".
//
// 4b. 'set-passcode' Pick a passcode (420 chars) POST
// /auth/passcode/set redirect to "/". A
// "Skip for now" link also redirects to "/".
//
// Server-side, /auth/otc/request returns 202 uniformly and
// /auth/passcode/check returns has_passcode=false for an unknown
// email, so this surface never distinguishes "we couldn't reach you"
// from "we don't know you" an unknown email always lands in the
// OTC path with 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 as a fallback in the footer so users with active
// OAuth sessions or older invite emails still have a path. We hide
// the fallback on 'capture-profile' so a half-captured pending user
// doesn't bail out into the OAuth path mid-form.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
import {
requestOtc,
verifyOtc,
submitBetaRequest,
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
} from '../api'
export default function Login() {
// Steps: 'email' 'passcode' or 'code' (on the OTC path, after
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
// (no passcode yet), or straight to "/". 'set-passcode' is reached
// from 'offer-passcode'.
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
// v0.8.0 step 3 capture fields.
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.8.0 capture-profile fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
@@ -45,13 +92,17 @@ export default function Login() {
const [busy, setBusy] = useState(false)
const emailRef = useRef(null)
const codeRef = useRef(null)
const passcodeRef = useRef(null)
const newPasscodeRef = useRef(null)
const firstNameRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else if (step === 'code') codeRef.current?.focus()
else if (step === 'profile') firstNameRef.current?.focus()
else if (step === 'passcode') passcodeRef.current?.focus()
else if (step === 'capture-profile') firstNameRef.current?.focus()
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
}, [step])
async function submitEmail(e) {
@@ -63,20 +114,72 @@ 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())
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
// pending), so we go straight to "/".
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) {
@@ -86,22 +189,39 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
const result = await verifyOtc(email.trim(), code.trim())
// v0.8.0 a fresh OTC user lands in `permission_state='pending'`
// with no profile fields. The verify response now carries a
// `needs_profile` flag the server stamped from the row state;
// surface the capture form here instead of jumping straight to
// "/". The fallback path (no flag, e.g. an older backend
// before the migration ran) jumps to "/" as before.
if (result?.needs_profile) {
setStep('profile')
await verifyOtc(email.trim(), code.trim())
// OTC verified the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile §6.1 capture (then /beta-pending).
// * no passcode §6.2 offer-passcode (then /).
// * otherwise /.
// needs_profile wins over has_passcode: a pending user can't yet
// do anything that benefits from faster sign-in, so we don't
// distract them with the passcode offer mid-admission.
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
let me = null
if (meResp.ok) {
try {
me = await meResp.json()
} catch (_) {
me = null
}
}
if (me?.needs_profile === true) {
setStep('capture-profile')
setStatus('')
setBusy(false)
return
}
// 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.
if (me?.has_passcode === false) {
setStep('offer-passcode')
setStatus('')
setBusy(false)
return
}
// Either /me returned the granted-with-passcode shape, or the
// call failed but the session cookie is set fall through to
// a hard reload so App.jsx re-fetches and renders accordingly.
window.location.assign('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
@@ -126,10 +246,10 @@ export default function Login() {
last_name: ln,
beta_request_reason: why,
})
// The user is still `permission_state='pending'`; bounce them
// to /beta-pending so the next thing they see is the
// "your request is in review" page. Hard-load so App.jsx
// re-fetches /api/auth/me and picks up the captured fields.
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
// the captured fields. The user stays permission_state='pending'
// until an admin grants access the next thing they should
// see is the "your request is in review" page.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
@@ -137,6 +257,26 @@ export default function Login() {
}
}
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') {
@@ -144,12 +284,59 @@ export default function Login() {
}
}
function onPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitPasscode(e)
}
}
function onReasonKey(e) {
// §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
// the reason textarea (the multi-line input that would otherwise
// swallow Enter as a newline).
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitProfile(e)
}
}
function onNewPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitNewPasscode(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">
@@ -157,7 +344,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}
@@ -170,10 +358,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">
@@ -211,7 +438,7 @@ export default function Login() {
</p>
</form>
)}
{step === 'profile' && (
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
@@ -245,6 +472,7 @@ export default function Login() {
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
onKeyDown={onReasonKey}
required
disabled={busy}
rows={5}
@@ -259,10 +487,71 @@ export default function Login() {
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
</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)}
onKeyDown={onNewPasscodeKey}
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>}
{step !== 'profile' && (
{step !== 'capture-profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
@@ -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() {