Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Stull 7872b921ed Release 0.9.0: admin user-management page + new-request notifications 2026-05-28 03:39:25 -07:00
14 changed files with 1204 additions and 119 deletions
+202
View File
@@ -337,6 +337,208 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
range (420) are hard-coded in `backend/app/passcode.py`. See
§19.2 for the env-tunable candidate.
## 0.9.0 — 2026-05-28
**Minor — no schema migration; reuses v0.8.0 columns and existing
SMTP.** This release ships the admin user-management surface at
`/admin/users` and the new-beta-request notifications that feed
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
compose: admins receive an inbox + email signal the moment a
pending user submits the v0.8.0 capture form; clicking through
lands on the page where they Grant or Revoke access.
The page consumes the v0.8.0 schema columns
(`permission_state`, `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by`,
`permission_decided_at`) without adding new ones — migration
slot 016 stays reserved for a future release. The single new
write endpoint, `POST /api/admin/users/<id>/permission`,
replaces v0.8.0's documented manual `UPDATE users SET
permission_state='granted'` gesture with an audited UI flip.
Admin notifications ride the existing §15 chokepoint — the
`new_beta_request` event_kind is added to the enum with
category `admin-actionable`, fan-out is to every owner / admin
minus the requester themselves, and the §15.4 email dispatch
only reaches recipients whose `email_admin_actionable` toggle
is on (the default for owners + admins). The event is the
framework's first non-RFC-scoped notification — `rfc_slug` is
NULL and the email deep-link points `/admin/users` instead of
`/rfc/<slug>`.
Decision on `/admin/allowlist`: the surface stays as a
sibling sub-tab, not folded into `/admin/users`. The two have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing
rather than clarifying. The allowlist's fast-path-bypass role
from v0.8.0 is unchanged; retiring the table outright is
deferred to a later session (see §19.2).
### Upgrade steps (from 0.10.0)
1. **MAY** rebuild the frontend. The build is the same shape
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
in `frontend/` updates it cleanly. No new env vars on the
frontend; the existing `VITE_APP_NAME` requirement carries
over.
2. **MAY** restart the backend. No schema migration runs in
this release — every v0.9.0 column is from
`014_beta_access.sql` (v0.8.0). Restart only if you want
the new endpoints registered in this version's binary.
3. **SHOULD** verify SMTP can reach the deployment's admin
inbox before the first pending user submits the capture
form. v0.9.0 reuses the v0.7.0 SMTP configuration
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
misconfigured deployment will still write the inbox row,
but the admin won't hear about it through email. No new
env var is required — the framework reuses the existing
admin-user list (role IN ('owner', 'admin')) as the
notification recipients.
4. **SHOULD** announce the surface to existing admins. Wording
suggestion: "There's now a Users tab in /admin where you can
Grant or Revoke beta access — and you'll get an email +
inbox row when a fresh request lands. The manual SQL gesture
from v0.8.0 still works but is no longer the documented
path."
5. **MAY** drain the existing pending queue through the new UI.
If your deployment carried pending users through the v0.8.0
manual-UPDATE window, the Pending bucket on `/admin/users`
surfaces all of them with their captured profile. Granting
from the UI stamps `permission_decided_by` /
`permission_decided_at`, which any prior manual UPDATE
gestures may have left NULL (no harm done — the v0.8.0
contract didn't require those stamps).
### Added
- **`POST /api/admin/users/<id>/permission`** — body
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
stamps `permission_decided_by` + `permission_decided_at`,
and writes a `permission_events` row with event_kind in
`{permission_granted, permission_revoked,
permission_repended}`. Refuses 422 on self-flip (symmetric
to `set_mute` / `set_role` self-action refusals) and 422
on invalid state. Returns `{ok, permission_state, changed}`
where `changed=false` indicates a no-op (the requested
state already matched).
- **Widened `GET /api/admin/users` response** carrying
`permission_state`, `first_name`, `last_name`,
`beta_request_reason`, `created_at`,
`permission_decided_at`, plus joined
`permission_decided_by_login` /
`permission_decided_by_display`. Sort order surfaces
`pending` rows first (the admin queue), then `granted`,
then `revoked`; within a bucket, owners precede admins
precede contributors, with recency as the tiebreaker.
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
by `notify.fan_out_new_beta_request` from the first
successful `POST /api/auth/me/beta-request` (re-submits
from the same pending user don't re-fire — the row's
first-time-complete check guards against carpet-bombing).
Recipients: every owner + admin minus the requester
themselves. Category: `admin-actionable`. Deep-link:
`/admin/users`. Actor: the requester per §15.9.
- **`/admin/users` page enhancements** in `Admin.jsx`. The
Users tab gains a state filter chip row (All / Pending /
Granted / Revoked with counts), a Grant / Revoke control
column, a Permission state badge, and an expandable
"why they want access" row beneath each pending user.
Sign-up timestamp surfaces in a new column.
- **`frontend/src/api.js#setUserPermission`** — client for
the new endpoint, neighboring `setUserMute` and
`setUserRole`.
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
"your request is in review" page now honestly references
the admin-email signal v0.9.0 ships and admits the
framework does not commit to an SLA — turnaround depends
on operator availability, and the deployment operator is
the right person to ask if a wait runs long. No
deployment-specific text is baked in; per-deployment copy
lives in §13 of the deployment's repo, not in the
framework.
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
tests covering: a beta-request submission fans
notifications to every admin + owner (and not to the
requester or to contributors); the requester's profile
fields land in the notification payload; re-submitting
the capture form does not re-fan; the `admin-actionable`
category mapping is wired; the `/api/admin/users`
listing carries the v0.8.0 columns with `pending` rows
sorted first; the permission-flip endpoint promotes
pending → granted with the right audit shape; the
endpoint promotes granted → revoked; the endpoint
refuses self-flip with 422; the endpoint refuses
non-admin callers with 403 and anonymous callers with
401; the endpoint refuses invalid states with 422; a
state-already-matches flip returns `changed=false`
without writing an audit row.
### Changed
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
handler now calls `notify.fan_out_new_beta_request` after the
capture UPDATE lands, gated on the row not previously having
all three profile fields populated (so re-submits don't
re-fan).
- **`backend/app/api_admin.py`** — the `list_users` query joins
against `users d ON d.id = u.permission_decided_by` for the
deciding-admin handle. The new `set_permission` endpoint
lives alongside `set_role` / `set_mute`.
- **`backend/app/notify.py`** — adds
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
helper, and the `new_beta_request` arm in `render_summary`.
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
carries `new_beta_request → admin-actionable`, and
`_deep_link` routes framework-scoped admin signals to
`/admin/users` instead of `/rfc/<slug>`.
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
component is rewritten with state filter chips, a per-row
`UserRow` + `PermissionCell` decomposition, and a
`useMemo`-cached counts table. The pending-row reason
blockquote renders as a secondary `<tr>` beneath the user
row when present.
- **`frontend/src/components/BetaPending.jsx`** — pending-state
copy revised to reference the admin email signal honestly.
- **`frontend/src/App.css`** — new admin-chip / permission-badge
/ user-row-reason rules.
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
endpoints, §19.2 candidates list — per §19.3 rule-2.
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
the lockfile mirror.
### Environment variables
None new. The release reuses the v0.7.0 SMTP configuration
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
admin-user list (role IN ('owner', 'admin')) as the
notification recipients. A deployment whose SMTP is misconfigured
will still see the inbox rows; only the email channel is muted.
### Deferred to later releases
- **Grant / revoke notification to the user** — symmetric
signal: when an admin grants or revokes access, fire a
`personal-direct` notification (event_kind
`permission_change_affecting_me`, already in the §15.1
enum) so the affected user sees the state change in
their inbox and email. v0.9.0 audits the gesture in
`permission_events` but does not yet escape the
app-internal log to the user. See §19.2.
- **Decline-with-reason on revoke** — the current Revoke
gesture takes only a confirmation; a follow-up release
may capture a free-text reason in
`permission_events.details`. See §19.2.
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
stays in place in v0.9.0 (the two surfaces have different
keys and a union row would be confusing). Retiring the
table outright is deferred to a session that can re-read
the post-v0.9.0 operator experience and decide whether
the fast-path-bypass role is still pulling weight. See
§19.2.
## 0.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
+102 -57
View File
@@ -398,9 +398,23 @@ endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0's
admin user-management page replaces the allowlist UI and ships the
pending-queue triage surface.
it — but the OTC request path no longer consults it. v0.9.0
(roadmap item #7) shipped the user-management surface that
consumes the `permission_state` column: `/admin/users` carries
every user with their state, profile, and sign-up reason, plus
Grant / Revoke controls that flip the column and write a
`permission_events` audit row. The capture-form submission also
fans a `new_beta_request` notification out to every admin/owner
through the §15 substrate, so the queue surfaces in the inbox +
email channels admins already have.
The `/admin/allowlist` sub-tab stays in place alongside
`/admin/users` rather than merging: the two surfaces have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing rather
than clarifying. The allowlist's role narrowed to "fast-path
bypass for known-good emails" with the v0.8.0 admission shift;
v0.9.0 retains that role unchanged.
### 6.1 Four roles, each a strict superset of the one below
@@ -2286,8 +2300,14 @@ signal taxonomy this section commits to. The starting set:
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
`rfc_reopened`, `claim_opened`, `claim_merged`,
`permission_change_affecting_me`, `app_wide_mute_set`,
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
build session adjusts as new gestures are wired in.
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
The enum is extensible; the build session adjusts as new gestures
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
#7) is framework-scoped rather than RFC-scoped — the row's
`rfc_slug` is NULL and the deep-link points `/admin/users`
instead of `/rfc/<slug>` — but otherwise rides the standard
fan-out chokepoint with category `admin-actionable` so the §15.4
email gate only reaches owners/admins.
### 15.2 The inbox
@@ -2945,8 +2965,15 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
fresh resolution branch and replay per §10.9.
- `GET /api/admin/users` — list users with role and write-mute state,
for the §6 / Slice 7 admin surface.
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
surface. v0.9.0 (roadmap item #7) widened the payload to carry
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
`created_at`, `permission_decided_at`, and the joined
`permission_decided_by_login` / `permission_decided_by_display`
for the user-management page. Sort order surfaces `pending` rows
first (the daily admin queue), then `granted`, then `revoked`;
within a bucket, owners precede admins precede contributors,
with recency as the tiebreaker.
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
or revoke `owner`; admins may flip contributor ↔ admin freely. An
owner-self-demotion is refused on this endpoint; owner succession
@@ -2955,6 +2982,16 @@ The follow-up session will refine this. A minimal starting set:
write-mute (not the §15.8 notification mutes). Refused on owners
and admins — for them, the role-change channel is the right
refusal. Writes a `permission_events` row.
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
Flip `permission_state` between `pending`, `granted`, and `revoked`.
Stamps `permission_decided_by` + `permission_decided_at` on the
row and writes a `permission_events` row with event_kind in
`{permission_granted, permission_revoked, permission_repended}`.
Refuses with 422 if the admin tries to flip their own row
(symmetric to the `set_mute` / `set_role` self-action refusals).
v0.8.0 shipped the column shape with no admin UI — operators ran
a manual `UPDATE users` to grant access; v0.9.0 retires the
manual gesture.
- `GET /api/admin/audit` — paged read of the `actions` log with
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
for the page boundary. Returns the joined actor login/display so
@@ -3794,56 +3831,64 @@ on every other page until an admin grants. v0.8.0 (roadmap item
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Surfaced by
v0.8.0 — the release ships the pending-state column but no
admin UI to triage it.* For v0.8.0, the admin gesture is an
out-of-band `UPDATE users SET permission_state='granted' WHERE
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
triage queue: a list of `permission_state='pending'` rows
sorted by `created_at`, each showing the captured first /
last / why fields, with Grant and Revoke buttons that stamp
`permission_decided_by` and `permission_decided_at` (schema
slots already in place per `migrations/014_beta_access.sql`).
The page composes naturally with the existing `/admin/allowlist`
surface — both are admission-control gestures so v0.9.0 may
fold the allowlist UI into this page (see next candidate).
Decision points: do grants / revokes also fire email
notifications to the user (probably yes — the notifications
layer from v0.6.0 has the personal-direct channel for it); is
there a "decline with reason" gesture that surfaces in the
user's view (probably yes — symmetric with §9.3's
proposal-decline shape); does the page support bulk grants
(probably no for v0.9.0 — the queue volume is operator-scale,
not user-scale). Earns its session as the v0.9.0 design pass.
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
`allowed_emails` table stays in the schema but the OTC
request path no longer consults it.* v0.8.0 left the table
and the `/admin/allowlist` UI in place as a fast-path bypass
for deployments that want to pre-mark known-good emails (the
v0.8.0 contract is that those emails still go through the
pending-grant flow; the table itself is no longer a gate). A
future release retires both — probably v0.9.0 alongside the
admin user-management page, since the two surfaces are
functionally redundant once the pending queue lands.
Decision points: drop the table outright (a schema migration)
or leave it as a non-functional surface and remove only the
UI (a frontend-only change); how to handle existing
`allowed_emails` rows at the cutover (probably: walk them
into the pending queue with `permission_state='granted'` for
any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its
session as a sub-topic of the v0.9.0 admin user-management
pass.
- **Admin notification on new beta request.** *Surfaced by
v0.8.0 — the capture endpoint writes to the row but does
not signal admins.* v0.9.0 candidate (item #7 again): when a
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
notification (per §15.4's category set) to every owner / admin
so the queue doesn't go stale. The §15 infrastructure already
supports the category; the open question is whether the
notification is per-request (one email per submission) or
digested (a daily summary). Earns its session alongside the
admin user-management page.
- **Admin user-management page** (`/admin/users`). *Shipped in
v0.9.0 (roadmap item #7).* The listing surfaces every user with
permission_state, profile fields, sign-up reason, and a Grant /
Revoke control set; the `POST /api/admin/users/<id>/permission`
endpoint flips the column and writes a `permission_events` row.
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
merging (see allowlist deprecation below). The grant/revoke
notify-the-user surface is deferred (see the new candidate
below).
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
v0.9.0 considered merging `/admin/allowlist` into the new
`/admin/users` page but kept the surface as a sibling sub-tab:
the two have different keys (allowlist by email pre-sign-up,
user list by user_id post-sign-up) and a union row would be
confusing rather than clarifying. The fast-path-bypass role
the allowlist has carried since v0.8.0 stays intact; the
cutover to retire the table outright is a later session.
Decision points unchanged from v0.8.0: drop the table outright
(a schema migration) or leave it as a non-functional surface
and remove only the UI (a frontend-only change); how to handle
existing `allowed_emails` rows at the cutover (probably: walk
them into the pending queue with `permission_state='granted'`
for any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its session
once the v0.9.0 admin queue has run long enough to confirm the
allowlist's bypass role is no longer pulling weight.
- **Admin notification on new beta request.** *Shipped in v0.9.0
(roadmap item #7).* The `POST /api/auth/me/beta-request`
handler now calls `notify.fan_out_new_beta_request`, which
fans a `new_beta_request` event (category `admin-actionable`,
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
handles the SSE broadcast and the §15.4 email dispatch; the
email reaches only recipients whose `email_admin_actionable`
toggle is on (the default for owners + admins).
- **Grant / revoke notification to the user.** *Surfaced by
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
writes a `permission_events` row but does not yet signal the
affected user that their state changed. A future release could
fire a `personal-direct` notification (event_kind
`permission_change_affecting_me`, already in the §15.1 enum) so
a granted user sees "Your beta-access request was approved" in
their inbox and email, and a revoked user sees a parallel
refusal notice. Decision points: does revocation include a
reason field (probably yes — symmetric with §9.3's decline
comment); does grant carry a welcome message (probably no —
the existing welcome surfaces are sufficient); does the
notification escape the §15.8 mute path (probably yes — it's
a personal-direct admission state change). Earns its session
as a follow-up to the v0.9.0 page.
- **Decline-with-reason on permission revoke.** *Surfaced by
v0.9.0.* The current Revoke gesture takes only a confirmation;
there is no audit-visible reason captured. A future release
could add a free-text reason input that lands in the
`permission_events.details` JSON column (no schema change
needed — the column is already JSON-shaped). This is the
symmetric companion to the §9.3 proposal-decline contract.
Earns its session alongside the grant/revoke notification
candidate above.
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
v0.7.0 keeps `/auth/callback` functional and links to it as a
"Sign in with Gitea (fallback)" affordance on the new `/login`
+1 -1
View File
@@ -1 +1 @@
0.14.0
0.9.0
+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}"
+425
View File
@@ -0,0 +1,425 @@
"""End-to-end integration tests for v0.9.0's admin user-management page
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
The release lands two halves of the same surface:
* **Admin notification on new beta request.** When a pending user
submits `POST /api/auth/me/beta-request`, every owner/admin
receives a `new_beta_request` notification (the §15 substrate
insert lands the row; the §15.4 email path dispatches subject to
the recipient's `email_admin_actionable` toggle).
* **Admin user-management surface** at `/admin/users`. The
`GET /api/admin/users` listing carries every user with their
permission_state, profile fields, sign-up reason, and decision
audit. The new `POST /api/admin/users/<id>/permission` endpoint
flips the column and writes a `permission_events` row.
The tests prove:
* The first beta-request submission fans a `new_beta_request`
row out to every admin/owner (and not to the requester
themselves). The row carries the captured profile in
`payload.extras`.
* Re-submitting the form from the same pending user doesn't
re-fan (we only notify on the row's first complete state).
* `GET /api/admin/users` carries the v0.9.0 columns
(permission_state, first/last/reason, decided_by).
* `POST /api/admin/users/<id>/permission` flips the state,
stamps decided_by/at, and writes a `permission_events` row.
* The endpoint refuses self-flip (422) and refuses non-admin
callers (403).
* The endpoint accepts only the three valid states (422 on
anything else).
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
def _provision_pending_user(client, email: str) -> int:
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
from app import db
_reset_outbound()
client.post("/auth/otc/request", json={"email": email})
code = _outbound_otc_codes(email)[-1]
client.post("/auth/otc/verify", json={"email": email, "code": code})
row = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
).fetchone()
return row["id"]
# ---------------------------------------------------------------------------
# Admin notification on beta-request submission
# ---------------------------------------------------------------------------
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
"""First-time submission of a beta-request fans a notification out
to every owner and admin. The requester themselves never receives
a row (filtered out by user_id even if they happened to be in the
admin set, which they aren't in practice — fresh OTC users are
`contributor`+`pending`)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Provision two admins and one owner so the fan-out has multiple
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
# here (no OAuth round-trip in this path); we seed the role
# directly.
provision_user_row(user_id=10, login="ownerzero", role="owner")
provision_user_row(user_id=11, login="admin_one", role="admin")
provision_user_row(user_id=12, login="admin_two", role="admin")
provision_user_row(user_id=13, login="contrib_one", role="contributor")
# Sign in a fresh OTC user → permission_state='pending'.
requester_id = _provision_pending_user(client, "newbie@example.com")
# Capture-form submit.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Newt",
"last_name": "Newcomer",
"beta_request_reason": "I want to write the Human RFC.",
},
)
assert r.status_code == 200, r.text
# Every owner + admin gets a `new_beta_request` notification.
# The contributor (id=13) does not. The requester (whoever id
# they got) does not.
rows = db.conn().execute(
"""
SELECT recipient_user_id, event_kind, actor_user_id, payload
FROM notifications
WHERE event_kind = 'new_beta_request'
"""
).fetchall()
recipients = sorted(r["recipient_user_id"] for r in rows)
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
# Actor is the requester (§15.9: never the bot).
for r in rows:
assert r["actor_user_id"] == requester_id
import json as _json
extras = _json.loads(r["payload"])
assert extras["requester_first_name"] == "Newt"
assert extras["requester_last_name"] == "Newcomer"
assert extras["requester_email"] == "newbie@example.com"
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
"""Once a user has completed the capture form, re-submitting it
(the endpoint is idempotent for pending users) must not re-fan a
fresh notification to every admin that would carpet-bomb the
inbox on every typo correction."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=20, login="adminzero", role="admin")
_provision_pending_user(client, "carpet@example.com")
body = {
"first_name": "Carpet",
"last_name": "Bomb",
"beta_request_reason": "first draft",
}
r1 = client.post("/api/auth/me/beta-request", json=body)
assert r1.status_code == 200
# Re-submit with edited reason — endpoint accepts (idempotent
# update), but the admin inbox stays at one row.
body2 = dict(body, beta_request_reason="cleaner final draft")
r2 = client.post("/api/auth/me/beta-request", json=body2)
assert r2.status_code == 200
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
).fetchone()
assert rows["n"] == 1
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
"""The §15.4 category mapping must route `new_beta_request` to the
admin-actionable bucket so the email gate consults
`email_admin_actionable` (and skips for non-admin recipients).
"""
from app import email as email_mod
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
# ---------------------------------------------------------------------------
# /api/admin/users — listing carries the v0.9.0 columns
# ---------------------------------------------------------------------------
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
"""The Users tab consumes this shape — confirm every required
column is on the response."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Seed an admin and a pending user with all the v0.8.0 columns
# populated. Direct-DB insert avoids the OTC dance (which would
# overwrite the cookie); the test above proves the capture
# pathway end-to-end and this one just exercises the listing
# surface's shape.
provision_user_row(user_id=30, login="ben", role="owner")
db.conn().execute(
"""
INSERT INTO users (id, gitea_id, gitea_login, email,
display_name, avatar_url, role,
permission_state, first_name, last_name,
beta_request_reason)
VALUES (31, NULL, NULL, 'pendinguser@example.com',
'pendinguser', '', 'contributor',
'pending', 'Penn', 'Ding', 'I want in.')
"""
)
sign_in_as(
client, user_id=30, gitea_login="ben",
display_name="Ben", role="owner",
)
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
assert isinstance(items, list)
pending = next(
(i for i in items if i["email"] == "pendinguser@example.com"), None,
)
assert pending is not None
assert pending["permission_state"] == "pending"
assert pending["first_name"] == "Penn"
assert pending["last_name"] == "Ding"
assert pending["beta_request_reason"] == "I want in."
assert pending["permission_decided_at"] is None
assert pending["permission_decided_by_login"] is None
# Pending bucket is listed first (sort order).
assert items[0]["permission_state"] == "pending"
# ---------------------------------------------------------------------------
# /api/admin/users/<id>/permission — the flip endpoint
# ---------------------------------------------------------------------------
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
flips them to granted via the endpoint, the row reflects the new
state + decided_by/at, and a `permission_events` audit row lands."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Pending user.
pending_id = _provision_pending_user(client, "flip@example.com")
# Admin acting on them.
provision_user_row(user_id=40, login="adminflipper", role="admin")
sign_in_as(
client, user_id=40, gitea_login="adminflipper",
display_name="Admin Flipper", role="admin",
)
r = client.post(
f"/api/admin/users/{pending_id}/permission",
json={"state": "granted"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["permission_state"] == "granted"
assert body["changed"] is True
# Row reflects the new state + decision stamp.
row = db.conn().execute(
"SELECT permission_state, permission_decided_by, permission_decided_at "
"FROM users WHERE id = ?",
(pending_id,),
).fetchone()
assert row["permission_state"] == "granted"
assert row["permission_decided_by"] == 40
assert row["permission_decided_at"] is not None
# Audit row landed in permission_events.
events = db.conn().execute(
"""
SELECT actor_user_id, subject_user_id, event_kind
FROM permission_events
WHERE event_kind = 'permission_granted'
"""
).fetchall()
assert len(events) == 1
assert events[0]["actor_user_id"] == 40
assert events[0]["subject_user_id"] == pending_id
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
"""Revoke is the symmetric gesture. Used when an account earned a
grant then later lost it (§6.1 / `revoked` state)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=50, login="goner", role="contributor")
# Default permission_state is 'granted' via the column default.
provision_user_row(user_id=51, login="adminrevoker", role="admin")
sign_in_as(
client, user_id=51, gitea_login="adminrevoker",
display_name="Admin Revoker", role="admin",
)
r = client.post(
"/api/admin/users/50/permission",
json={"state": "revoked"},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 50"
).fetchone()
assert row["permission_state"] == "revoked"
events = db.conn().execute(
"SELECT event_kind FROM permission_events "
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
).fetchall()
assert len(events) == 1
def test_permission_flip_refuses_self(app_with_fake_gitea):
"""Symmetric to set_mute / set_role: an admin can't self-flip.
The state-change channel for one's own grant is somebody else's
hand."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=60, login="selfflipper", role="admin")
sign_in_as(
client, user_id=60, gitea_login="selfflipper",
display_name="Self Flipper", role="admin",
)
r = client.post(
"/api/admin/users/60/permission",
json={"state": "revoked"},
)
assert r.status_code == 422
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
"""The endpoint is admin-only (§17 admin/* requires require_admin).
A contributor caller is refused 403; an anonymous caller 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=70, login="target", role="contributor")
provision_user_row(user_id=71, login="contrib", role="contributor")
sign_in_as(
client, user_id=71, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 403
client.cookies.clear()
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 401
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
"""Pydantic regex pattern refuses anything outside the three
canonical states with 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=80, login="targetx", role="contributor")
provision_user_row(user_id=81, login="adminx", role="admin")
sign_in_as(
client, user_id=81, gitea_login="adminx",
display_name="Admin X", role="admin",
)
r = client.post(
"/api/admin/users/80/permission",
json={"state": "banished"},
)
assert r.status_code == 422
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
"""An admin flipping a granted user to granted gets 200 with
`changed: false` no audit row, no decided_at update."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
provision_user_row(user_id=91, login="adminN", role="admin")
sign_in_as(
client, user_id=91, gitea_login="adminN",
display_name="Admin N", role="admin",
)
before_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
r = client.post(
"/api/admin/users/90/permission",
json={"state": "granted"},
)
assert r.status_code == 200
body = r.json()
assert body["changed"] is False
after_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
assert after_events == before_events
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.14.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+48
View File
@@ -1889,6 +1889,54 @@
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
/* v0.9.0 — admin user-management surface (roadmap item #7). */
.admin-filter-chips {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
}
.admin-chip:hover { background: #f9fafb; }
.admin-chip.active {
background: #111; color: #fff; border-color: #111;
}
.admin-chip-count {
font-size: 11px; opacity: 0.7;
}
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
.permission-actions { display: flex; gap: 6px; }
.permission-badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em;
width: max-content;
}
.permission-badge-pending {
background: #fef3c7; color: #92400e;
}
.permission-badge-granted {
background: #dcfce7; color: #166534;
}
.permission-badge-revoked {
background: #fee2e2; color: #991b1b;
}
.permission-decided { font-size: 11px; }
.user-row-reason td {
background: #fffbeb; border-top: none !important;
padding: 0 16px 12px !important;
}
.user-reason-block {
border-left: 3px solid #f59e0b;
padding: 8px 12px; font-size: 13px;
background: #fffbeb;
}
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
+13
View File
@@ -661,6 +661,19 @@ export async function setUserMute(userId, muted) {
}))
}
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
// page wires Grant / Revoke buttons against this endpoint; the
// returned `changed` flag is false when the requested state already
// matched the row.
export async function setUserPermission(userId, state) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
+192 -52
View File
@@ -16,6 +16,7 @@ import {
listAdminUsers,
setUserRole,
setUserMute,
setUserPermission,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
@@ -68,12 +69,26 @@ export default function Admin({ viewer }) {
)
}
// Users + role + write-mute (§6.1 / §6.2)
// Users + role + write-mute + permission grant/revoke (§6.1 / §6.2)
//
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
// shows every user with their permission_state, sign-up reason (when
// pending), role, write-mute, and Grant / Revoke controls. State filter
// chips above the table narrow to one bucket the "Pending" chip is the
// admin's daily inbox shape.
const STATE_CHIPS = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'granted', label: 'Granted' },
{ value: 'revoked', label: 'Revoked' },
]
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
async function refresh() {
setError(null)
@@ -113,68 +128,193 @@ function UsersTab() {
}
}
async function flipPermission(userId, state) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserPermission(userId, state)
// Refresh the full row so permission_decided_{at,by_*} update too.
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
const counts = useMemo(() => {
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
if (users) {
c.all = users.length
for (const u of users) {
const s = u.permission_state || 'granted'
if (s in c) c[s] += 1
}
}
return c
}, [users])
if (users == null) return <p className="muted">Loading users</p>
const filtered = stateFilter === 'all'
? users
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
and stamps <code>permission_decided_by</code> +{' '}
<code>permission_decided_at</code>. Role and write-mute controls
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.value}
type="button"
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
onClick={() => setStateFilter(chip.value)}
>
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="muted">No users in this bucket.</p>
) : (
<table className="admin-table admin-users-table">
<thead>
<tr>
<th>User</th>
<th>State</th>
<th>Role</th>
<th>Write-muted</th>
<th>Signed up</th>
<th>Last seen</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filtered.map(u => (
<UserRow
key={u.id}
user={u}
busy={!!busy[u.id]}
onChangeRole={role => changeRole(u.id, role)}
onToggleMute={muted => toggleMute(u.id, muted)}
onFlipPermission={state => flipPermission(u.id, state)}
/>
))}
</tbody>
</table>
)}
</div>
)
}
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
const state = u.permission_state || 'granted'
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
</span>
</div>
</td>
<td>
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
</td>
<td>
<select
value={u.role}
onChange={e => onChangeRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => onToggleMute(e.target.checked)}
disabled={busy}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
</tr>
{state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason">
<td colSpan={6}>
<div className="user-reason-block">
<strong>Why they want access:</strong>
<p>{u.beta_request_reason}</p>
</div>
</td>
</tr>
) : null}
</>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
: ''
return (
<div className="permission-cell">
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
<div className="permission-actions">
{state !== 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => onFlipPermission('granted')}
>Grant</button>
)}
{state === 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => {
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
onFlipPermission('revoked')
}
}}
>Revoke</button>
)}
</div>
{decidedSuffix && (
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
)}
</div>
)
}
+5 -2
View File
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
Thanks for telling us a bit about yourself. The deployment's
admins are notified by email as soon as a request lands;
we don't commit to a fixed SLA turnaround depends on
operator availability and the deployment operator is
the right person to ask if a wait runs long.
</p>
<p>
While you wait, the catalog on the left lists every super-draft