Release 0.9.0: admin user-management page + new-request notifications
This commit is contained in:
+117
-4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user