1456c8b73f
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.
From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).
Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.
Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.
Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).
Amplitude wiring (inline, #21 Part C):
- USER_INVITED from CreateUserInviteModal { target_user_id,
initial_role, custom_message_chars }
- INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
initial_role, needs_passcode, trust_device }
- identify() BEFORE the claim event with properties:
claim_method 'admin-invite', invited_at (setOnce),
invited_by_admin_id (setOnce), initial_role (setOnce) —
so the Amplitude user record is created with the OHM
user_id from the very first event the invitee fires
Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
920 lines
38 KiB
Python
920 lines
38 KiB
Python
"""§17 admin endpoints + Slice 7 user search.
|
|
|
|
The admin's repertoire grew across the prior slices without earning a
|
|
centralized home: role grants (§6.1), the §6.2 app-wide write-mute, the
|
|
§6.5 / §5 audit logs, and the §13 graduation-readiness queue. Slice 7
|
|
consolidates them behind `/api/admin/*` so the chrome can hold them in
|
|
one tabbed surface (`Admin.jsx`).
|
|
|
|
The endpoints in this module:
|
|
|
|
- `GET /api/admin/users` — list users with role + mute
|
|
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
|
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
|
- `POST /api/admin/users` — v0.17.0: create user + invite
|
|
- `GET /api/admin/users/invites` — v0.17.0: pending invites
|
|
- `GET /api/admin/audit` — paged `actions` log
|
|
- `GET /api/admin/permission-events` — paged `permission_events` log
|
|
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
|
- `GET /api/users/search?q=…` — typeahead for §15.8 mute add
|
|
|
|
Permission gates: every `/api/admin/*` endpoint requires
|
|
`require_admin` (owner or admin); the role-change endpoint additionally
|
|
refuses any non-owner caller granting `owner`, since owner-zero is the
|
|
only owner bootstrap path per §6.1 and a downgrade from owner needs the
|
|
sitting owner's hand. The user-search endpoint is open to any
|
|
authenticated viewer — it powers the §15.8 mute typeahead and contains
|
|
no privileged information beyond what every authenticated viewer
|
|
already sees in the catalog and chat-author surfaces.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import auth, db, email_invite, invites
|
|
from .config import Config
|
|
from .email import EmailConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic bodies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RoleBody(BaseModel):
|
|
role: str = Field(pattern="^(owner|admin|contributor)$")
|
|
|
|
|
|
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)
|
|
|
|
|
|
class CreateUserInviteBody(BaseModel):
|
|
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
|
|
|
|
The admin types these fields on the "Create user + invite" modal on
|
|
`/admin/users`. The email + role are required; first/last name and
|
|
the optional custom message round out the body.
|
|
|
|
Bounds mirror the rest of the codebase:
|
|
* `email`: 320 chars — RFC 5321 envelope limit, same as
|
|
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
|
|
* `first_name` / `last_name`: 120 chars — same as the v0.8.0
|
|
`BetaRequestBody` capture form.
|
|
* `role`: pydantic regex pinned to the §6.1 set so an unknown
|
|
role fails at the body bound (422) instead of landing as a
|
|
CHECK constraint violation in the migration.
|
|
* `custom_message`: 500 chars — the brief calls this out as
|
|
the max. The frontend modal shows a "remaining chars"
|
|
counter to match.
|
|
"""
|
|
email: str = Field(min_length=3, max_length=320)
|
|
first_name: str = Field(default="", max_length=120)
|
|
last_name: str = Field(default="", max_length=120)
|
|
role: str = Field(pattern="^(owner|admin|contributor)$")
|
|
custom_message: str = Field(default="", max_length=500)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Router
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_router(config: Config) -> APIRouter:
|
|
del config # unused for now; reserved for future per-deployment knobs
|
|
router = APIRouter()
|
|
|
|
# ----- User listing -----
|
|
|
|
@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.
|
|
|
|
v0.16.0 (roadmap item #12) additive: each user row now carries
|
|
an `rfc_invitations` array — the per-RFC invitations the user
|
|
has accepted. This is the "permission-grant requests from
|
|
invited users" hook the roadmap text calls for: when a user
|
|
accepts a per-RFC invite and they're not yet platform-granted,
|
|
the admin sees "here because @ben invited them to <RFC> as
|
|
<role>" alongside their pending row, informing (not deciding)
|
|
the platform grant. The two write surfaces remain distinct —
|
|
the RFC's owner controls per-RFC roles; the admin controls
|
|
platform-grant state.
|
|
"""
|
|
auth.require_admin(request)
|
|
rows = db.conn().execute(
|
|
"""
|
|
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()
|
|
# v0.16.0 — per-user accepted per-RFC invitations. One query
|
|
# over the full set, indexed bucket-by-user-id in Python so
|
|
# the per-row attachment below is O(1). Empty array for users
|
|
# who hold no accepted invitations.
|
|
invitation_rows = db.conn().execute(
|
|
"""
|
|
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
|
|
r.title AS rfc_title,
|
|
i.id AS invitation_id, i.invitee_email,
|
|
ui.gitea_login AS inviter_login,
|
|
ui.display_name AS inviter_display
|
|
FROM rfc_collaborators c
|
|
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
|
|
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
|
|
LEFT JOIN users ui ON ui.id = i.inviter_user_id
|
|
ORDER BY c.created_at DESC
|
|
"""
|
|
).fetchall()
|
|
per_user_invites: dict[int, list[dict]] = {}
|
|
for ir in invitation_rows:
|
|
per_user_invites.setdefault(ir["user_id"], []).append({
|
|
"rfc_slug": ir["rfc_slug"],
|
|
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
|
|
"role_in_rfc": ir["role_in_rfc"],
|
|
"invited_at": ir["created_at"],
|
|
"invitation_id": ir["invitation_id"],
|
|
"invitee_email": ir["invitee_email"],
|
|
"inviter_login": ir["inviter_login"],
|
|
"inviter_display": ir["inviter_display"],
|
|
})
|
|
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
|
|
# is NULL is one of two things — a brand-new row that was just
|
|
# provisioned (rare, and the v0.7.0 OTC verify path stamps
|
|
# last_seen_at on the same call that creates the row), or an
|
|
# admin-created invite-pending row (v0.17.0 — created by
|
|
# `POST /api/admin/users`). We surface a `pending_invite_id`
|
|
# field by joining through `user_invite_tokens` so the
|
|
# Users tab can render a "(pending invite)" badge alongside
|
|
# the role/state controls. Filters to invites that are
|
|
# neither expired nor claimed — once the invitee clicks
|
|
# through, the badge clears (and `last_seen_at` populates).
|
|
pending_invite_rows = db.conn().execute(
|
|
"""
|
|
SELECT invited_user_id, id AS invite_id, expires_at
|
|
FROM user_invite_tokens
|
|
WHERE claimed_at IS NULL
|
|
AND datetime(expires_at) > datetime('now')
|
|
"""
|
|
).fetchall()
|
|
pending_invites = {
|
|
r["invited_user_id"]: {
|
|
"invite_id": r["invite_id"],
|
|
"expires_at": r["expires_at"],
|
|
}
|
|
for r in pending_invite_rows
|
|
}
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"gitea_login": r["gitea_login"],
|
|
"display_name": r["display_name"],
|
|
"email": r["email"] or "",
|
|
"role": r["role"],
|
|
"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"],
|
|
# v0.16.0 additive — never null, always an array.
|
|
"rfc_invitations": per_user_invites.get(r["id"], []),
|
|
# v0.17.0: present iff the row is invited-but-not-
|
|
# claimed-yet. The frontend renders a "(pending
|
|
# invite)" badge when this is non-null.
|
|
"pending_invite": pending_invites.get(r["id"]),
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
|
|
|
|
@router.post("/api/admin/users")
|
|
async def create_user_with_invite(
|
|
body: CreateUserInviteBody, request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Provision a fresh `users` row with a pre-assigned role + send
|
|
an invite email carrying a claim link.
|
|
|
|
Refusals:
|
|
* `422` — the admin tries to invite their own email (no
|
|
self-invite; symmetric to `set_permission`'s self-flip
|
|
refusal and `set_role`'s self-downgrade refusal). Use
|
|
the existing role-change channel for self-edits.
|
|
* `422` — the admin tries to grant `owner` without being
|
|
owner themselves. §6.1: owner-zero is the only owner
|
|
bootstrap path; new owners come from a sitting owner's
|
|
hand. A 422 here matches the message shape; a 403 would
|
|
also be defensible, but staying with 422 keeps the
|
|
"your input is bad" framing.
|
|
* `409` — the email already maps to a `users` row. The
|
|
admin should use the existing role / grant gestures on
|
|
the existing user, not create a duplicate.
|
|
* `422` — pydantic-level: malformed email, role outside
|
|
the §6.1 set, custom_message over 500 chars.
|
|
|
|
On success:
|
|
1. The invitee `users` row lands with the chosen role and
|
|
`permission_state='granted'` (admin's hand is the grant)
|
|
and `last_seen_at IS NULL` (the "(pending invite)"
|
|
discriminator the listing surface joins through).
|
|
2. The `user_invite_tokens` row lands with the bcrypt-
|
|
hashed opaque token; the raw token rides only in the
|
|
email link.
|
|
3. The invite email dispatches with subject "You're
|
|
invited to <app> by <admin>" and the custom message
|
|
embedded in a clearly-delimited block if present.
|
|
4. A `permission_events` row records the admin-create
|
|
gesture so the §6.5 / `permissions` admin tab carries
|
|
the audit trail alongside the existing grant/revoke
|
|
flips.
|
|
"""
|
|
viewer = auth.require_admin(request)
|
|
email_clean = body.email.strip().lower()
|
|
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
|
|
raise HTTPException(422, "Email looks malformed")
|
|
|
|
# Self-invite refusal. Compare the admin's own email
|
|
# case-insensitively against the invite target.
|
|
viewer_row = db.conn().execute(
|
|
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
|
|
).fetchone()
|
|
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
|
|
if viewer_email and viewer_email == email_clean:
|
|
raise HTTPException(
|
|
422,
|
|
"You cannot invite yourself — use the role-change channel "
|
|
"if you need to edit your own row",
|
|
)
|
|
|
|
# Owner-grant refusal: §6.1 says only a sitting owner can mint
|
|
# a new owner. An admin trying to invite-as-owner is refused
|
|
# at 422; the admin should ask the owner to issue the invite,
|
|
# or invite as `admin` and let the owner promote later.
|
|
if body.role == "owner" and viewer.role != "owner":
|
|
raise HTTPException(
|
|
422,
|
|
"Only an owner can invite a new owner — invite as admin and "
|
|
"ask the owner to promote, or have the owner issue this invite",
|
|
)
|
|
|
|
# Duplicate-email refusal. A pre-existing row (regardless of
|
|
# permission_state) means the admin should use the existing
|
|
# role / grant gestures, not create a parallel user.
|
|
existing = db.conn().execute(
|
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
|
|
(email_clean,),
|
|
).fetchone()
|
|
if existing is not None:
|
|
raise HTTPException(409, "A user with this email already exists")
|
|
|
|
# Create the invitee row + token row + send the email.
|
|
outcome = invites.create_invite(
|
|
email=email_clean,
|
|
first_name=body.first_name,
|
|
last_name=body.last_name,
|
|
role=body.role,
|
|
custom_message=body.custom_message,
|
|
created_by_admin_id=viewer.user_id,
|
|
)
|
|
|
|
# Audit row in permission_events so the admin Permissions tab
|
|
# carries the gesture. The before-state is "n/a" (the row
|
|
# did not exist); the after-state is the granted role. We
|
|
# use a new `event_kind='user_invited'` so the existing
|
|
# grant/revoke kinds stay scoped to their flip surface.
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO permission_events
|
|
(actor_user_id, subject_user_id, event_kind, details)
|
|
VALUES (?, ?, 'user_invited', ?)
|
|
""",
|
|
(
|
|
viewer.user_id,
|
|
outcome.invited_user_id,
|
|
json.dumps({
|
|
"email": email_clean,
|
|
"role": body.role,
|
|
"invite_id": outcome.invite_id,
|
|
"custom_message_chars": len(body.custom_message or ""),
|
|
}),
|
|
),
|
|
)
|
|
|
|
# Build the claim URL using the same APP_URL the email module
|
|
# reads. The token rides as a query-string param to the
|
|
# frontend route `/invites/claim?token=…`; the frontend POSTs
|
|
# it back to `/api/invites/claim` which consumes the row.
|
|
cfg = EmailConfig.from_env()
|
|
from urllib.parse import urlencode
|
|
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
|
|
|
|
# Fetch the inviter display so the email body can render
|
|
# "Ben Stull (ben@example.com) has invited you to …". We
|
|
# read off the row fresh rather than trusting the session
|
|
# cookie's cached display_name.
|
|
inviter_row = db.conn().execute(
|
|
"SELECT display_name, email FROM users WHERE id = ?",
|
|
(viewer.user_id,),
|
|
).fetchone()
|
|
inviter_display = (
|
|
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
|
|
)
|
|
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
|
|
|
|
email_invite.send_invite_email(
|
|
to_address=email_clean,
|
|
claim_url=claim_url,
|
|
inviter_display=inviter_display,
|
|
inviter_email=inviter_email_for_body,
|
|
custom_message=body.custom_message,
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"invite_id": outcome.invite_id,
|
|
"invited_user_id": outcome.invited_user_id,
|
|
"email": email_clean,
|
|
"role": body.role,
|
|
}
|
|
|
|
@router.get("/api/admin/users/invites")
|
|
async def list_user_invites(request: Request) -> dict[str, Any]:
|
|
"""List active (not claimed, not expired) admin-issued invites.
|
|
|
|
Powers the admin's "I sent these but they haven't been claimed
|
|
yet" view. The frontend uses this alongside `list_users` —
|
|
the user-listing's `pending_invite` field carries the per-row
|
|
flag; this endpoint carries the full invite shape for a
|
|
dedicated drill-in surface.
|
|
"""
|
|
auth.require_admin(request)
|
|
rows = invites.list_pending_invites()
|
|
# Join through to the admin display names so the surface can
|
|
# render "invited by @ben" without a second client call.
|
|
admin_ids = {r.created_by_admin_id for r in rows}
|
|
admin_lookup: dict[int, dict[str, str]] = {}
|
|
if admin_ids:
|
|
placeholders = ",".join("?" * len(admin_ids))
|
|
admin_rows = db.conn().execute(
|
|
f"SELECT id, gitea_login, display_name FROM users "
|
|
f"WHERE id IN ({placeholders})",
|
|
tuple(admin_ids),
|
|
).fetchall()
|
|
admin_lookup = {
|
|
ar["id"]: {
|
|
"gitea_login": ar["gitea_login"] or "",
|
|
"display_name": ar["display_name"] or "",
|
|
}
|
|
for ar in admin_rows
|
|
}
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r.id,
|
|
"email": r.email,
|
|
"role": r.role,
|
|
"first_name": r.first_name,
|
|
"last_name": r.last_name,
|
|
"custom_message": r.custom_message,
|
|
"created_at": r.created_at,
|
|
"expires_at": r.expires_at,
|
|
"invited_user_id": r.invited_user_id,
|
|
"created_by_admin_id": r.created_by_admin_id,
|
|
"created_by_login": admin_lookup.get(
|
|
r.created_by_admin_id, {}
|
|
).get("gitea_login", ""),
|
|
"created_by_display": admin_lookup.get(
|
|
r.created_by_admin_id, {}
|
|
).get("display_name", ""),
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
# ----- Role change (§6.1) -----
|
|
|
|
@router.post("/api/admin/users/{user_id}/role")
|
|
async def set_role(user_id: int, body: RoleBody, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_admin(request)
|
|
target = db.conn().execute(
|
|
"SELECT id, role, gitea_login FROM users WHERE id = ?", (user_id,)
|
|
).fetchone()
|
|
if target is None:
|
|
raise HTTPException(404, "User not found")
|
|
# §6.1: only an owner can grant `owner`, and only an owner can
|
|
# revoke an owner. Admins can flip contributor ↔ admin freely.
|
|
if body.role == "owner" and viewer.role != "owner":
|
|
raise HTTPException(403, "Only an owner can grant the owner role")
|
|
if target["role"] == "owner" and viewer.role != "owner":
|
|
raise HTTPException(403, "Only an owner can change an owner's role")
|
|
# An owner cannot demote themselves — that would orphan owner-zero
|
|
# if they are the only owner, and the role-change UI should not
|
|
# smuggle a "downgrade self" path through this endpoint.
|
|
if target["id"] == viewer.user_id and body.role != viewer.role:
|
|
raise HTTPException(403, "Use the explicit succession path to change your own role")
|
|
|
|
before = target["role"]
|
|
if before == body.role:
|
|
return {"ok": True, "role": body.role, "changed": False}
|
|
|
|
db.conn().execute(
|
|
"UPDATE users SET role = ? WHERE id = ?", (body.role, user_id),
|
|
)
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO permission_events
|
|
(actor_user_id, subject_user_id, event_kind, details)
|
|
VALUES (?, ?, 'role_changed', ?)
|
|
""",
|
|
(
|
|
viewer.user_id,
|
|
user_id,
|
|
json.dumps({"before": before, "after": body.role}),
|
|
),
|
|
)
|
|
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")
|
|
async def set_mute(user_id: int, body: MuteBody, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_admin(request)
|
|
target = db.conn().execute(
|
|
"SELECT id, role, muted 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 write-mute yourself")
|
|
# §6.2 + §6.1: admins/owners are not write-mutable. The write-mute
|
|
# is a contributor-only refusal — an admin's authority is the
|
|
# role-change channel, not a mute.
|
|
if target["role"] in ("owner", "admin"):
|
|
raise HTTPException(
|
|
403,
|
|
"Owners and admins cannot be write-muted — change the role instead",
|
|
)
|
|
|
|
before = bool(target["muted"])
|
|
after = bool(body.muted)
|
|
if before == after:
|
|
return {"ok": True, "muted": after, "changed": False}
|
|
|
|
db.conn().execute(
|
|
"UPDATE users SET muted = ? WHERE id = ?", (1 if after else 0, user_id),
|
|
)
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO permission_events
|
|
(actor_user_id, subject_user_id, event_kind, details)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(
|
|
viewer.user_id,
|
|
user_id,
|
|
"muted" if after else "restored",
|
|
json.dumps({"before": before, "after": after}),
|
|
),
|
|
)
|
|
return {"ok": True, "muted": after, "changed": True}
|
|
|
|
# ----- Audit log (`actions` + `permission_events`) -----
|
|
|
|
@router.get("/api/admin/audit")
|
|
async def list_audit(
|
|
request: Request,
|
|
action_kind: str | None = None,
|
|
actor_user_id: int | None = None,
|
|
rfc_slug: str | None = None,
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
before_id: int | None = None,
|
|
) -> dict[str, Any]:
|
|
auth.require_admin(request)
|
|
clauses: list[str] = []
|
|
args: list[Any] = []
|
|
if action_kind:
|
|
clauses.append("a.action_kind = ?")
|
|
args.append(action_kind)
|
|
if actor_user_id is not None:
|
|
clauses.append("a.actor_user_id = ?")
|
|
args.append(actor_user_id)
|
|
if rfc_slug:
|
|
clauses.append("a.rfc_slug = ?")
|
|
args.append(rfc_slug)
|
|
if before_id is not None:
|
|
clauses.append("a.id < ?")
|
|
args.append(before_id)
|
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
rows = db.conn().execute(
|
|
f"""
|
|
SELECT a.id, a.actor_user_id, a.on_behalf_of, a.action_kind,
|
|
a.rfc_slug, a.branch_name, a.pr_number, a.bot_commit_sha,
|
|
a.details, a.created_at,
|
|
u.gitea_login AS actor_login, u.display_name AS actor_display
|
|
FROM actions a
|
|
LEFT JOIN users u ON u.id = a.actor_user_id
|
|
{where}
|
|
ORDER BY a.id DESC
|
|
LIMIT ?
|
|
""",
|
|
(*args, limit),
|
|
).fetchall()
|
|
# The distinct-action-kinds list powers the filter chip in
|
|
# `Admin.jsx`; cheap to compute alongside the page since the
|
|
# action_kind set is bounded.
|
|
kinds = [
|
|
r["action_kind"]
|
|
for r in db.conn().execute(
|
|
"SELECT DISTINCT action_kind FROM actions ORDER BY action_kind"
|
|
)
|
|
]
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"action_kind": r["action_kind"],
|
|
"actor_user_id": r["actor_user_id"],
|
|
"actor_login": r["actor_login"],
|
|
"actor_display": r["actor_display"],
|
|
"on_behalf_of": r["on_behalf_of"],
|
|
"rfc_slug": r["rfc_slug"],
|
|
"branch_name": r["branch_name"],
|
|
"pr_number": r["pr_number"],
|
|
"bot_commit_sha": r["bot_commit_sha"],
|
|
"details": _safe_json(r["details"]),
|
|
"created_at": r["created_at"],
|
|
}
|
|
for r in rows
|
|
],
|
|
"action_kinds": kinds,
|
|
"has_more": len(rows) == limit,
|
|
}
|
|
|
|
@router.get("/api/admin/permission-events")
|
|
async def list_permission_events(
|
|
request: Request,
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
before_id: int | None = None,
|
|
) -> dict[str, Any]:
|
|
auth.require_admin(request)
|
|
clauses: list[str] = []
|
|
args: list[Any] = []
|
|
if before_id is not None:
|
|
clauses.append("p.id < ?")
|
|
args.append(before_id)
|
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
rows = db.conn().execute(
|
|
f"""
|
|
SELECT p.id, p.event_kind, p.details, p.created_at,
|
|
p.actor_user_id, p.subject_user_id,
|
|
au.gitea_login AS actor_login, au.display_name AS actor_display,
|
|
su.gitea_login AS subject_login, su.display_name AS subject_display
|
|
FROM permission_events p
|
|
LEFT JOIN users au ON au.id = p.actor_user_id
|
|
LEFT JOIN users su ON su.id = p.subject_user_id
|
|
{where}
|
|
ORDER BY p.id DESC
|
|
LIMIT ?
|
|
""",
|
|
(*args, limit),
|
|
).fetchall()
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"event_kind": r["event_kind"],
|
|
"actor_login": r["actor_login"],
|
|
"actor_display": r["actor_display"],
|
|
"subject_login": r["subject_login"],
|
|
"subject_display": r["subject_display"],
|
|
"details": _safe_json(r["details"]),
|
|
"created_at": r["created_at"],
|
|
}
|
|
for r in rows
|
|
],
|
|
"has_more": len(rows) == limit,
|
|
}
|
|
|
|
# ----- Graduation-readiness queue (§13.2) -----
|
|
|
|
@router.get("/api/admin/graduation-queue")
|
|
async def graduation_queue(request: Request) -> dict[str, Any]:
|
|
auth.require_admin(request)
|
|
# §13 / §13.2: a super-draft is ready when (a) it has at least one
|
|
# owner claimed via §13.1 and (b) it has zero open meta_body_edit
|
|
# PRs. We compute both with one pass, returning the ready set and
|
|
# the not-yet-ready set so the admin sees what is gating each.
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT slug, title, owners_json, arbiters_json, tags_json,
|
|
proposed_at, last_entry_commit_at
|
|
FROM cached_rfcs
|
|
WHERE state = 'super-draft'
|
|
ORDER BY COALESCE(last_entry_commit_at, proposed_at) DESC
|
|
"""
|
|
).fetchall()
|
|
items_ready: list[dict[str, Any]] = []
|
|
items_blocked: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
owners = json.loads(r["owners_json"] or "[]")
|
|
blocking = db.conn().execute(
|
|
"""
|
|
SELECT COUNT(*) AS n FROM cached_prs
|
|
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
|
""",
|
|
(r["slug"],),
|
|
).fetchone()["n"]
|
|
payload = {
|
|
"slug": r["slug"],
|
|
"title": r["title"],
|
|
"owners": owners,
|
|
"tags": json.loads(r["tags_json"] or "[]"),
|
|
"proposed_at": r["proposed_at"],
|
|
"last_entry_commit_at": r["last_entry_commit_at"],
|
|
"blocking_prs": blocking,
|
|
"owners_set": len(owners) > 0,
|
|
}
|
|
if payload["owners_set"] and blocking == 0:
|
|
items_ready.append(payload)
|
|
else:
|
|
items_blocked.append(payload)
|
|
return {"ready": items_ready, "blocked": items_blocked}
|
|
|
|
# ----- User search (typeahead for §15.8 mute add) -----
|
|
|
|
@router.get("/api/users/search")
|
|
async def search_users(
|
|
request: Request, q: str = Query(default="", min_length=0, max_length=80),
|
|
) -> dict[str, Any]:
|
|
viewer = auth.require_user(request)
|
|
needle = q.strip().lower()
|
|
# An empty query returns recent users; a non-empty query matches
|
|
# against gitea_login and display_name with a prefix-first
|
|
# ranking so the typeahead surfaces obvious matches early.
|
|
if not needle:
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT id, gitea_login, display_name, role
|
|
FROM users WHERE id != ?
|
|
ORDER BY last_seen_at DESC LIMIT 10
|
|
""",
|
|
(viewer.user_id,),
|
|
).fetchall()
|
|
else:
|
|
like = f"%{needle}%"
|
|
prefix = f"{needle}%"
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT id, gitea_login, display_name, role
|
|
FROM users
|
|
WHERE id != ?
|
|
AND (LOWER(gitea_login) LIKE ? OR LOWER(display_name) LIKE ?)
|
|
ORDER BY
|
|
CASE WHEN LOWER(gitea_login) LIKE ? THEN 0 ELSE 1 END,
|
|
LOWER(gitea_login)
|
|
LIMIT 10
|
|
""",
|
|
(viewer.user_id, like, like, prefix),
|
|
).fetchall()
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"gitea_login": r["gitea_login"],
|
|
"display_name": r["display_name"],
|
|
"role": r["role"],
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
# ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----
|
|
|
|
@router.get("/api/admin/allowlist")
|
|
async def list_allowlist(request: Request) -> dict[str, Any]:
|
|
auth.require_admin(request)
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT a.email, a.note, a.created_at,
|
|
u.gitea_login AS added_by_login,
|
|
u.display_name AS added_by_display
|
|
FROM allowed_emails a
|
|
LEFT JOIN users u ON u.id = a.added_by_user_id
|
|
ORDER BY a.created_at DESC
|
|
"""
|
|
).fetchall()
|
|
return {
|
|
"active": len(rows) > 0,
|
|
"items": [
|
|
{
|
|
"email": r["email"],
|
|
"note": r["note"] or "",
|
|
"added_by_login": r["added_by_login"],
|
|
"added_by_display": r["added_by_display"],
|
|
"created_at": r["created_at"],
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
|
|
@router.post("/api/admin/allowlist")
|
|
async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_admin(request)
|
|
email = body.email.strip()
|
|
if "@" not in email or len(email.split("@")[-1]) < 2:
|
|
raise HTTPException(422, "Email looks malformed")
|
|
existing = db.conn().execute(
|
|
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
|
).fetchone()
|
|
if existing is not None:
|
|
raise HTTPException(409, "Email already on the allowlist")
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO allowed_emails (email, added_by_user_id, note)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(email, viewer.user_id, body.note),
|
|
)
|
|
# Audit trail: when the email already maps to a known user, emit a
|
|
# permission_events row so §6.5's log stays the single place to
|
|
# look for "who let this person in." For brand-new emails the
|
|
# allowed_emails row itself carries (added_by_user_id, created_at)
|
|
# which is sufficient until the user actually signs in.
|
|
subject = db.conn().execute(
|
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
|
|
).fetchone()
|
|
if subject is not None:
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO permission_events
|
|
(actor_user_id, subject_user_id, event_kind, details)
|
|
VALUES (?, ?, 'allowlist_added', ?)
|
|
""",
|
|
(
|
|
viewer.user_id,
|
|
subject["id"],
|
|
json.dumps({"email": email, "note": body.note or ""}),
|
|
),
|
|
)
|
|
return {"ok": True, "email": email}
|
|
|
|
@router.delete("/api/admin/allowlist/{email}")
|
|
async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_admin(request)
|
|
existing = db.conn().execute(
|
|
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
|
).fetchone()
|
|
if existing is None:
|
|
raise HTTPException(404, "Email not on the allowlist")
|
|
db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
|
|
subject = db.conn().execute(
|
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
|
|
).fetchone()
|
|
if subject is not None:
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO permission_events
|
|
(actor_user_id, subject_user_id, event_kind, details)
|
|
VALUES (?, ?, 'allowlist_removed', ?)
|
|
""",
|
|
(
|
|
viewer.user_id,
|
|
subject["id"],
|
|
json.dumps({"email": email}),
|
|
),
|
|
)
|
|
return {"ok": True, "email": email}
|
|
|
|
return router
|
|
|
|
|
|
def _safe_json(blob: str | None) -> Any:
|
|
if not blob:
|
|
return None
|
|
try:
|
|
return json.loads(blob)
|
|
except Exception:
|
|
return blob
|