Release 0.8.0: open beta-access request flow (first/last/why)

Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 02:25:59 -07:00
parent 8aa65014b4
commit ca8ba69acb
17 changed files with 1327 additions and 145 deletions
+40 -19
View File
@@ -1,38 +1,59 @@
// BetaPending.jsx — the post-OAuth-rejection page.
// BetaPending.jsx — the "your request is in review" page (§6.1 / §14.1).
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel — so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
// user whose email wasn't on the `allowed_emails` table bounced here).
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
// page: any authenticated user whose `permission_state='pending'` lands
// here on root visits, after a fresh-OTC profile capture, or via the
// header "Your beta access is in review" affordance.
//
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
// email, URL, or short instruction). If unset, we render a generic
// ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
export default function BetaPending({ viewer }) {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
</h1>
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
</p>
<p>
While you wait, the catalog on the left lists every super-draft
and active RFC in the framework reading is open. Discussion
and contribution unlock once your access is granted.
</p>
</>
) : (
<p>
Discussion and contribution are gated to invited contributors for
now. Reading is open every super-draft, every active RFC, and
every public conversation is visible without signing in.
</p>
)}
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
Questions? Contact <strong>{contact}</strong>.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
Questions? Contact the deployment operator.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-primary" to="/">Browse the catalog</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
+128 -20
View File
@@ -1,41 +1,57 @@
// Login.jsx — v0.7.0's primary sign-in surface (§6.2).
// 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.
//
// Two-step:
// 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, redirect to the post-login landing. Cmd/Ctrl+Enter
// on the code field is the keyboard shortcut.
// → 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.
//
// 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.
// 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.
//
// 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.
// 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.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc } from '../api'
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
export default function Login() {
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
// v0.8.0 — step 3 capture fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
const emailRef = useRef(null)
const codeRef = useRef(null)
const firstNameRef = 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 === 'profile') firstNameRef.current?.focus()
}, [step])
async function submitEmail(e) {
@@ -70,7 +86,19 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await verifyOtc(email.trim(), code.trim())
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')
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.
@@ -81,6 +109,34 @@ export default function Login() {
}
}
async function submitProfile(e) {
if (e) e.preventDefault()
const fn = firstName.trim()
const ln = lastName.trim()
const why = reason.trim()
if (!fn || !ln || !why) {
setStatus('All three fields are required.')
return
}
setBusy(true)
setStatus('')
try {
await submitBetaRequest({
first_name: fn,
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.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
@@ -155,12 +211,64 @@ export default function Login() {
</p>
</form>
)}
{step === 'profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting' : 'Submit request'}
</button>
</div>
</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>
{step !== 'profile' && (
<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>
)