Release 0.3.0: private-beta gate + anonymous read mode

Adds an email allowlist (toggleable per deployment) that restricts
OAuth sign-in to listed emails while keeping read paths public.
Anonymous visitors now see the full app shell in read-only mode
instead of the §14.1 landing wall. Empty allowlist = gate off, so
deployments that don't enable it behave exactly as 0.2.3.

Also fixes single-finger scroll on /philosophy and other .chrome-pane
views on iOS Safari (.app: 100vh → 100dvh).

Renames deploy/nginx/rfc.wiggleverse.org.conf →
ohm.wiggleverse.org.conf to match the deployed-domain rename
(rfc.wiggleverse.org deprovisioned 2026-05-27).

See CHANGELOG.md for full details + upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-27 20:58:58 -07:00
parent 1a9374aa52
commit 21fcbc92d4
19 changed files with 715 additions and 87 deletions
+139
View File
@@ -19,10 +19,14 @@ import {
listAuditLog,
listPermissionEvents,
listGraduationQueue,
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js'
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
{ path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' },
@@ -54,6 +58,7 @@ export default function Admin({ viewer }) {
<Routes>
<Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
@@ -174,6 +179,140 @@ function UsersTab() {
)
}
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
function AllowlistTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [draftEmail, setDraftEmail] = useState('')
const [draftNote, setDraftNote] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
setError(null)
try {
setData(await listAllowlist())
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function handleAdd(event) {
event.preventDefault()
const email = draftEmail.trim()
if (!email) return
setBusy(true); setError(null)
try {
await addAllowlistEmail(email, draftNote.trim() || null)
setDraftEmail(''); setDraftNote('')
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
async function handleRemove(email) {
if (!confirm(`Remove ${email} from the allowlist?`)) return
setBusy(true); setError(null)
try {
await removeAllowlistEmail(email)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
if (data == null && !error) return <p className="muted">Loading allowlist…</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Allowlist</h2>
<p className="muted">
When this list has any rows, OAuth sign-in is restricted: only emails
here (case-insensitive) may sign in. Already-provisioned users are
grandfathered by their Gitea ID and never re-checked. An empty list
turns the gate off entirely.
</p>
<p className="muted">
Status:{' '}
<strong>{data?.active ? 'Private beta gate active' : 'Open anyone can sign in'}</strong>
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<form className="allowlist-add" onSubmit={handleAdd}>
<input
type="email"
placeholder="email@example.com"
value={draftEmail}
onChange={e => setDraftEmail(e.target.value)}
required
disabled={busy}
/>
<input
type="text"
placeholder="Note (optional)"
value={draftNote}
onChange={e => setDraftNote(e.target.value)}
maxLength={200}
disabled={busy}
/>
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
Add to allowlist
</button>
</form>
{data?.items?.length > 0 ? (
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Note</th>
<th>Added by</th>
<th>Added at</th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.email}>
<td><code>{r.email}</code></td>
<td>{r.note || <span className="muted">—</span>}</td>
<td>
{r.added_by_login
? <span>@{r.added_by_login}</span>
: <span className="muted">—</span>}
</td>
<td className="muted">{r.created_at}</td>
<td>
<button
type="button"
className="btn-link-quiet"
onClick={() => handleRemove(r.email)}
disabled={busy}
>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">
No allow-listed emails yet. Add the first one to put the deployment
into private-beta mode.
</p>
)}
</div>
)
}
// ── Graduation-readiness queue (§13.2) ─────────────────────────────────────
function GraduationTab() {
+41
View File
@@ -0,0 +1,41 @@
// BetaPending.jsx the post-OAuth-rejection page.
//
// 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.
import { Link } from 'react-router-dom'
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
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>
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
</div>
)
}
+9 -3
View File
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
{ id: 'state', label: 'State' },
]
export default function Catalog({ onProposeRFC, version }) {
export default function Catalog({ viewer, onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('')
@@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) {
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.'
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
: 'No matches.'}
</div>
) : (
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
</div>
<div className="catalog-footer">
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
{viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div>
</aside>
)
+8 -3
View File
@@ -535,9 +535,10 @@ export default function RFCView({ viewer }) {
type="button"
className={`btn-mode-toggle ${mode}`}
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
title={mode === 'discuss' ? 'Flip into edit mode' : 'Flip back to read-only discuss'}
title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
>
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
<span className="beta-chip">Beta</span>
</button>
)}
{(branchParam === 'main' || !canContribute) && viewer && (
@@ -547,10 +548,13 @@ export default function RFCView({ viewer }) {
onClick={handleStartContributing}
>
Start Contributing
<span className="beta-chip">Beta</span>
</button>
)}
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a>
<a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
{canOpenPR && (
<button
@@ -742,7 +746,8 @@ export default function RFCView({ viewer }) {
/>
) : (
<div className="readonly-bar">
Read-only view. <a href="/auth/login">Sign in</a> to participate.
Read-only view. Discussion is in private <strong>Beta</strong> {' '}
<a href="/auth/login">sign in</a> if your email has been invited.
</div>
)}
</div>