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
+22
View File
@@ -172,6 +172,26 @@ def _oauth_router(config) -> APIRouter:
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
# or jump straight to "/". `needs_profile=true` iff the user
# is `permission_state='pending'` AND the row has no profile
# fields yet — a fresh OTC user. Grandfathered users
# (`permission_state='granted'`) and pending users who already
# captured their fields both read as false.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
needs_profile = (
result.user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"ok": True,
"user": {
@@ -179,7 +199,9 @@ def _oauth_router(config) -> APIRouter:
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
"needs_profile": needs_profile,
}
return router