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:
+81
-42
@@ -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 BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
@@ -68,17 +69,14 @@ export default function App() {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
|
||||
// §14.2: the philosophy route is reachable by anonymous visitors too.
|
||||
// Resolve it before the authentication gate so a signed-out reader
|
||||
// who follows the §14.1 landing link does not get bounced to sign-in.
|
||||
if (!me?.authenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
|
||||
<Route path="*" element={<Landing />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
// The deployment is in private beta: anonymous visitors get the full
|
||||
// app in read-only mode (viewer = null is passed through to every
|
||||
// component), and write affordances are hidden at the component
|
||||
// level. /beta-pending is the post-OAuth-rejection page reachable by
|
||||
// anyone. The original §14.1 Landing surface is retained for the
|
||||
// `/welcome` URL only, in case a deployment wants to link to it.
|
||||
const viewer = me?.authenticated ? me.user : null
|
||||
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -88,59 +86,78 @@ export default function App() {
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* §14.3: the persistent About link. One word, no badge, no
|
||||
state — visible from every authenticated screen so a
|
||||
contributor mid-PR who wonders why a conversation is
|
||||
public can reach the answer in two clicks. */}
|
||||
state — visible from every screen so a viewer mid-PR who
|
||||
wonders why a conversation is public can reach the answer
|
||||
in two clicks. Anonymous viewers see it too. */}
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
{(me.user.role === 'owner' || me.user.role === 'admin') && (
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className="header-admin" title="Admin home base">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
{viewer && (
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{viewer ? (
|
||||
<>
|
||||
<span className="user-name">{viewer.display_name}</span>
|
||||
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
||||
<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">
|
||||
Sign in <span className="beta-chip">Beta</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/beta-pending" element={<BetaPending />} />
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||
{viewer && (
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
<Route path="*" element={
|
||||
<>
|
||||
<Catalog
|
||||
viewer={viewer}
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
version={catalogVersion}
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
<Route path="/" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && (
|
||||
{proposeOpen && viewer && (
|
||||
<ProposeModal
|
||||
onClose={() => setProposeOpen(false)}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
@@ -150,7 +167,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && (
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<ToastHost />
|
||||
@@ -158,14 +175,14 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function PhilosophyWithSidebar() {
|
||||
function PhilosophyWithSidebar({ viewer }) {
|
||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||
// all use the full app body — no catalog left pane, no propose modal.
|
||||
// The header carries the navigation back; the body is a single
|
||||
// reading surface.
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Philosophy authenticated={true} />
|
||||
<Philosophy authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -187,6 +204,28 @@ function AdminWithSidebar({ viewer }) {
|
||||
}
|
||||
|
||||
function Welcome({ viewer }) {
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="welcome">
|
||||
<h1>Welcome.</h1>
|
||||
<p>
|
||||
The catalog on the left lists every super-draft and active RFC in the
|
||||
framework. Open one to read the canonical body and the public
|
||||
conversation behind each definition.
|
||||
</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
|
||||
been invited.
|
||||
</p>
|
||||
<p>
|
||||
Wondering why a conversation is public, why graduation costs what it
|
||||
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||||
philosophy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="welcome">
|
||||
<h1>Welcome, {viewer.display_name}.</h1>
|
||||
|
||||
Reference in New Issue
Block a user