Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)

Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 00:55:29 -07:00
parent c92730a737
commit 2590c244d6
18 changed files with 1483 additions and 18 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.7.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.5.0",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+83
View File
@@ -352,6 +352,89 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
.otc-login {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 40px 24px;
}
.otc-login-inner {
max-width: 360px;
width: 100%;
display: flex; flex-direction: column;
gap: 14px;
}
.otc-login h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.otc-login .otc-hint {
color: #555;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.otc-login input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.otc-login input:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-login button[type="submit"] {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 10px 18px;
font-size: 14px; font-weight: 600;
cursor: pointer;
}
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.otc-login form {
display: flex; flex-direction: column;
gap: 10px;
}
.otc-actions {
display: flex; align-items: center; gap: 12px;
}
.otc-login .btn-link-quiet {
background: none; border: none;
color: #666; font-size: 13px;
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
.otc-shortcut-hint kbd {
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
padding: 1px 5px; font-size: 11px; font-family: inherit;
}
.otc-status {
color: #555; font-size: 13px;
background: #f7f6f0;
border-left: 3px solid #cfc8a8;
padding: 8px 12px;
margin: 4px 0 0;
}
.otc-fallback {
font-size: 12px; color: #777;
margin: 16px 0 0;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.otc-fallback a, .otc-fallback .otc-fallback-link {
color: #666; text-decoration: none;
}
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
+5 -3
View File
@@ -8,6 +8,7 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
@@ -121,15 +122,16 @@ export default function App() {
<a className="btn-link" href="/auth/logout">Sign out</a>
</>
) : (
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
</Link>
)}
</div>
</header>
<div className="app-body">
<Routes>
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{viewer && (
@@ -216,7 +218,7 @@ function Welcome({ viewer }) {
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <a href="/auth/login">sign in</a> if your email has
read freely, and <Link to="/login">sign in</Link> if your email has
been invited.
</p>
<p>
+24
View File
@@ -25,6 +25,30 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email) {
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code) {
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
})
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Landing() {
first RFC defining <em>human</em>. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="btn-signin" to="/login">Sign in</Link>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
+167
View File
@@ -0,0 +1,167 @@
// Login.jsx v0.7.0's primary sign-in surface (§6.2).
//
// 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.
//
// 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.
//
// 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.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc } from '../api'
export default function Login() {
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
const emailRef = useRef(null)
const codeRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else codeRef.current?.focus()
}, [step])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
setStatus('Enter a valid email address.')
return
}
setBusy(true)
setStatus('')
try {
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.')
}
} finally {
setBusy(false)
}
}
async function submitCode(e) {
if (e) e.preventDefault()
if (!code.trim() || code.trim().length !== 6) {
setStatus('Enter the six-digit code from your email.')
return
}
setBusy(true)
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('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitCode(e)
}
}
function backToEmail() {
setStep('email')
setCode('')
setStatus('')
}
return (
<div className="otc-login">
<div className="otc-login-inner">
<h1>Sign in</h1>
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. We'll send you a one-time code.
</p>
<input
ref={emailRef}
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
Enter the six-digit code we sent to <strong>{email}</strong>.
</p>
<input
ref={codeRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={onCodeKey}
placeholder="123456"
required
disabled={busy}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
</p>
</form>
)}
{status && <p className="otc-status">{status}</p>}
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
<a href="/auth/login">Sign in with Gitea (fallback)</a>
</p>
</div>
</div>
)
}