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:
@@ -55,6 +55,17 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class BetaRequestBody(BaseModel):
|
||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||
# required so the admin queue has a coherent triage shape.
|
||||
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
|
||||
# headers; 4000 for the free-text reason — the same upper bound
|
||||
# DeclineBody uses elsewhere in this file).
|
||||
first_name: str = Field(min_length=1, max_length=120)
|
||||
last_name: str = Field(min_length=1, max_length=120)
|
||||
beta_request_reason: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
@@ -120,6 +131,28 @@ def make_router(
|
||||
user = auth.current_user(request)
|
||||
if user is None:
|
||||
return {"authenticated": False, "user": None}
|
||||
# v0.8.0: surface `permission_state` plus the capture-flow
|
||||
# readiness signal (`needs_profile`). The frontend gates
|
||||
# the /beta-pending page and the inline banner off these
|
||||
# fields, and decides whether to prompt for the first/last/why
|
||||
# capture on first OTC sign-in.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(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" iff the user is pending AND hasn't yet
|
||||
# filed their beta-request capture. Granted users never see
|
||||
# the capture prompt; pending users who already filed see
|
||||
# the /beta-pending page without the capture form.
|
||||
needs_profile = (
|
||||
user.permission_state == "pending"
|
||||
and not first_name
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -129,9 +162,68 @@ def make_router(
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"permission_state": user.permission_state,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"beta_request_reason": beta_request_reason,
|
||||
"needs_profile": needs_profile,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
||||
# (roadmap item #6). Lands first name, last name, and the free-
|
||||
# text "why I should be included in the beta" on the signed-in
|
||||
# user's row. Idempotent for the same already-pending user;
|
||||
# refuses to overwrite a row that's already granted (so a
|
||||
# bored already-granted user can't accidentally re-submit the
|
||||
# form and clobber the admin's audit trail). Uses
|
||||
# `require_user` rather than `require_contributor` because
|
||||
# `require_contributor` already enforces `permission_state =
|
||||
# 'granted'` and would refuse a pending user; the whole point
|
||||
# of this endpoint is to register the request _from_ a pending
|
||||
# user.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/auth/me/beta-request")
|
||||
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Defensive — the session pointed at a deleted row.
|
||||
raise HTTPException(404, "User not found")
|
||||
# Granted users have no business filing a beta request.
|
||||
# 'revoked' likewise — the request flow is for fresh users
|
||||
# only. Both shapes refuse with 409 (conflict) so the client
|
||||
# can distinguish "you already have access" from
|
||||
# "your access was revoked".
|
||||
if row["permission_state"] == "granted":
|
||||
raise HTTPException(409, "Your account is already granted access")
|
||||
if row["permission_state"] == "revoked":
|
||||
raise HTTPException(409, "Your account's access has been revoked")
|
||||
# Re-submission from a pending user updates the row — the
|
||||
# admin sees the latest text rather than a stale draft.
|
||||
# The state stays 'pending'; only an admin can flip it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET first_name = ?,
|
||||
last_name = ?,
|
||||
beta_request_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
body.first_name.strip(),
|
||||
body.last_name.strip(),
|
||||
body.beta_request_reason.strip(),
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7: the catalog
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user