Release 0.9.0: admin user-management page + new-request notifications

This commit is contained in:
Ben Stull
2026-05-28 03:39:25 -07:00
parent de28272914
commit 7872b921ed
14 changed files with 1204 additions and 119 deletions
+13
View File
@@ -31,6 +31,7 @@ from . import (
cache,
funder,
health,
notify,
philosophy,
providers as providers_mod,
)
@@ -237,6 +238,18 @@ def make_router(
user.user_id,
),
)
# v0.9.0 (roadmap item #7): notify every admin/owner of the
# fresh request. Only the first submission is the
# "newly-pending" gesture — re-submits from the same user
# would otherwise carpet the admin inbox. We fire only when
# this is the row's first time getting all three fields
# populated (the prior row carried at least one NULL).
prior = row # captured before the UPDATE above
was_already_complete = bool(
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
)
if not was_already_complete:
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
+117 -4
View File
@@ -50,6 +50,16 @@ class MuteBody(BaseModel):
muted: bool
class PermissionStateBody(BaseModel):
# v0.9.0: the admin flip from the user-management page (roadmap
# item #7). `pending` is not surfaceable from the admin UI —
# only the OTC verify path lands a row in `pending` — but we
# accept it in the pattern in case a future restore-to-queue
# gesture wants to re-pend a granted user; today the UI only
# exposes `granted` and `revoked`.
state: str = Field(pattern="^(pending|granted|revoked)$")
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
@@ -68,13 +78,41 @@ def make_router(config: Config) -> APIRouter:
@router.get("/api/admin/users")
async def list_users(request: Request) -> dict[str, Any]:
"""v0.9.0: the user-management surface (roadmap item #7).
The listing carries every column the admin queue needs to triage
pending beta-access requests alongside the existing role/mute
affordances. Sort order surfaces pending requests first (so the
admin lands on the inbox shape), then granted, then revoked;
within a state, ownership/role and recency are the tiebreakers
so the legacy ordering (owner first, then admin, then by name)
is preserved inside the granted bucket.
`permission_decided_by_login` joins the deciding admin row so
the UI can render "granted by @ben" without a second round-trip.
"""
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, email, role, muted,
created_at, last_seen_at
FROM users
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
u.created_at, u.last_seen_at,
u.permission_state, u.first_name, u.last_name,
u.beta_request_reason,
u.permission_decided_by, u.permission_decided_at,
d.gitea_login AS decided_by_login,
d.display_name AS decided_by_display
FROM users u
LEFT JOIN users d ON d.id = u.permission_decided_by
ORDER BY
CASE u.permission_state
WHEN 'pending' THEN 0
WHEN 'granted' THEN 1
WHEN 'revoked' THEN 2
ELSE 3
END,
u.role = 'owner' DESC, u.role = 'admin' DESC,
COALESCE(u.last_seen_at, u.created_at) DESC,
u.display_name COLLATE NOCASE
"""
).fetchall()
return {
@@ -88,6 +126,13 @@ def make_router(config: Config) -> APIRouter:
"muted": bool(r["muted"]),
"created_at": r["created_at"],
"last_seen_at": r["last_seen_at"],
"permission_state": r["permission_state"] or "granted",
"first_name": r["first_name"] or "",
"last_name": r["last_name"] or "",
"beta_request_reason": r["beta_request_reason"] or "",
"permission_decided_at": r["permission_decided_at"],
"permission_decided_by_login": r["decided_by_login"],
"permission_decided_by_display": r["decided_by_display"],
}
for r in rows
]
@@ -136,6 +181,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True, "role": body.role, "changed": True}
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
@router.post("/api/admin/users/{user_id}/permission")
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
"""Flip a user's `permission_state` between pending/granted/revoked.
v0.8.0 wired the column shape but shipped no admin UI for it —
the grant gesture was a manual `UPDATE users` against the DB.
v0.9.0 (roadmap item #7) lands the admin user-management page;
this endpoint is its single write surface.
Audit shape: every flip writes a `permission_events` row with
event_kind in {'permission_granted', 'permission_revoked',
'permission_repended'} so §6.5's log carries the change. The
`permission_decided_by` / `permission_decided_at` columns on
the user row are co-stamped so the user listing can render
"granted by @ben at <date>" without a second join through
the audit table.
Refuses with 422 if the admin tries to flip their own row
(no self-grant / self-revoke; symmetric to set_mute's
self-mute refusal and set_role's self-downgrade refusal).
"""
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, permission_state FROM users WHERE id = ?",
(user_id,),
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
if target["id"] == viewer.user_id:
raise HTTPException(422, "You cannot change your own permission state")
before = target["permission_state"] or "granted"
after = body.state
if before == after:
return {"ok": True, "permission_state": after, "changed": False}
db.conn().execute(
"""
UPDATE users
SET permission_state = ?,
permission_decided_by = ?,
permission_decided_at = datetime('now')
WHERE id = ?
""",
(after, viewer.user_id, user_id),
)
event_kind = {
"granted": "permission_granted",
"revoked": "permission_revoked",
"pending": "permission_repended",
}[after]
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, ?, ?)
""",
(
viewer.user_id,
user_id,
event_kind,
json.dumps({"before": before, "after": after}),
),
)
return {"ok": True, "permission_state": after, "changed": True}
# ----- Write-mute (§6.2) -----
@router.post("/api/admin/users/{user_id}/mute")
+11
View File
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
"graduation_complete": "personal-direct",
"super_draft_graduation_ready": "admin-actionable",
"claim_opened": "structural",
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
# an admin-actionable signal so it consults `email_admin_actionable`
# and reaches owners/admins only.
"new_beta_request": "admin-actionable",
}
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
slug = payload.get("rfc_slug")
pr = payload.get("pr_number")
branch = payload.get("branch_name")
event_kind = payload.get("event_kind")
# v0.9.0: framework-scoped admin signals link to the admin
# surface, not /rfc/... The `new_beta_request` event is the
# canonical example; future framework-scoped admin events
# may reuse the same branch.
if event_kind == "new_beta_request":
return f"{cfg.app_url}/admin/users"
if slug and pr:
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
if slug and branch:
+72
View File
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
CATEGORY_PERSONAL = "personal-direct"
CATEGORY_STRUCTURAL = "structural"
CATEGORY_CHURN = "churn"
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
# Action kinds whose actor's first interaction with a slug triggers
# auto-watch per §15.6. The substantive-gesture list in the spec is
@@ -208,6 +209,67 @@ def fan_out_from_action(
)
def fan_out_new_beta_request(
*,
requester_user_id: int,
) -> None:
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
every admin/owner.
Called from `POST /api/auth/me/beta-request` after the row's
first/last/why fields are populated. Fan-out shape mirrors the §15
chokepoint contract: one row per recipient, written via `_emit_one`
so the SSE broadcast + email dispatch run through the same surface
every other notification uses. The event has no rfc_slug (it is
framework-scoped, not RFC-scoped); the deep-link payload points
`/admin/users` instead of `/rfc/<slug>`.
Actor is the requester per §15.9 (the underlying user, never the
bot). Category is `admin-actionable` so the §15.4 email gate
consults `email_admin_actionable` (owners/admins-only by
construction) and the digest exclusion rules treat it identically
to other admin-actionable signals (graduation_ready et al).
Recipients are owners + admins minus the requester themselves
(a self-promotion shouldn't reach the requester's own inbox). The
requester is never in the role set in practice the endpoint
refuses 'granted'/'revoked' callers and a fresh OTC user lands
`contributor`+`pending` but we filter regardless so the call
is robust to future changes in the auth gate.
"""
requester = db.conn().execute(
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
(requester_user_id,),
).fetchone()
if requester is None:
return
first = (requester["first_name"] or "").strip()
last = (requester["last_name"] or "").strip()
email = requester["email"] or ""
display = requester["display_name"] or email or "a new user"
full_name = (f"{first} {last}").strip() or display
details = {
"requester_user_id": requester_user_id,
"requester_first_name": first,
"requester_last_name": last,
"requester_email": email,
"requester_display": full_name,
}
for recipient_id in _admin_user_ids():
if recipient_id == requester_user_id:
continue
_emit_one(
recipient_user_id=recipient_id,
event_kind="new_beta_request",
category=CATEGORY_ADMIN_ACTIONABLE,
actor_user_id=requester_user_id,
rfc_slug=None,
branch_name=None,
pr_number=None,
details=details,
)
def fan_out_chat_message(
*,
actor_user_id: int,
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as
# one self-contained sentence; the inbox row and the email
# body share this text per §15.4.
full_name = extras.get("requester_display") or actor
email_addr = extras.get("requester_email") or ""
if email_addr:
return f"New beta-access request from {full_name} ({email_addr})."
return f"New beta-access request from {full_name}."
return f"{event_kind} on {title}"