From 7872b921edb0b1883a22284e519479a995535918 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 03:39:25 -0700 Subject: [PATCH] Release 0.9.0: admin user-management page + new-request notifications --- CHANGELOG.md | 202 ++++++++++ SPEC.md | 159 +++++--- VERSION | 2 +- backend/app/api.py | 13 + backend/app/api_admin.py | 121 +++++- backend/app/email.py | 11 + backend/app/notify.py | 72 ++++ backend/tests/test_admin_users_vertical.py | 425 +++++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.css | 48 +++ frontend/src/api.js | 13 + frontend/src/components/Admin.jsx | 244 +++++++++--- frontend/src/components/BetaPending.jsx | 7 +- 14 files changed, 1204 insertions(+), 119 deletions(-) create mode 100644 backend/tests/test_admin_users_vertical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c411273..05d9191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -337,6 +337,208 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length range (4–20) 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//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/`. + +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//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/`. +- **`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 `` 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.** diff --git a/SPEC.md b/SPEC.md index 5ecea9e..b91bbb7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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/` — 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//prs//withdraw` — withdraw per §10.8. - `POST /api/rfcs//prs//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//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//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//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` diff --git a/VERSION b/VERSION index a803cc2..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.0 +0.9.0 diff --git a/backend/app/api.py b/backend/app/api.py index 8d8edcf..7bc342e 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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} # --------------------------------------------------------------- diff --git a/backend/app/api_admin.py b/backend/app/api_admin.py index f130381..f4031de 100644 --- a/backend/app/api_admin.py +++ b/backend/app/api_admin.py @@ -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 " 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") diff --git a/backend/app/email.py b/backend/app/email.py index 8e28974..fdd0e93 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -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: diff --git a/backend/app/notify.py b/backend/app/notify.py index 96b8922..7835c3e 100644 --- a/backend/app/notify.py +++ b/backend/app/notify.py @@ -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/`. + + 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}" diff --git a/backend/tests/test_admin_users_vertical.py b/backend/tests/test_admin_users_vertical.py new file mode 100644 index 0000000..a6f9b2e --- /dev/null +++ b/backend/tests/test_admin_users_vertical.py @@ -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//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//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//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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71d2cd9..0aec94b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 7d4027f..1c4dc54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.14.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index bb8c830..622ca9b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } diff --git a/frontend/src/api.js b/frontend/src/api.js index 76a4967..19f5df1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx index 027c880..0f85c13 100644 --- a/frontend/src/components/Admin.jsx +++ b/frontend/src/components/Admin.jsx @@ -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

Loading users…

+ const filtered = stateFilter === 'all' + ? users + : users.filter(u => (u.permission_state || 'granted') === stateFilter) + return (

Users

- Role changes write to permission_events. 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 permission_events + and stamps permission_decided_by +{' '} + permission_decided_at. 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.

{error &&

{error}

} - - - - - - - - - - - {users.map(u => ( - - - - - + +
+ {STATE_CHIPS.map(chip => ( + + ))} +
+ + {filtered.length === 0 ? ( +

No users in this bucket.

+ ) : ( +
UserRoleWrite-mutedLast seen
-
- @{u.gitea_login} - {u.display_name} -
-
- - - {u.role === 'contributor' ? ( - - ) : ( - N/A - )} - {u.last_seen_at}
+ + + + + + + + - ))} - -
UserStateRoleWrite-mutedSigned upLast seen
+ + + {filtered.map(u => ( + changeRole(u.id, role)} + onToggleMute={muted => toggleMute(u.id, muted)} + onFlipPermission={state => flipPermission(u.id, state)} + /> + ))} + + + )} +
+ ) +} + +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 ( + <> + + +
+ {handle} + + {fullName || u.display_name} + {u.email ? ` · ${u.email}` : ''} + +
+ + + + + + + + + {u.role === 'contributor' ? ( + + ) : ( + N/A + )} + + {u.created_at || '—'} + {u.last_seen_at || '—'} + + {state === 'pending' && u.beta_request_reason ? ( + + +
+ Why they want access: +

{u.beta_request_reason}

+
+ + + ) : 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 ( +
+ {state} +
+ {state !== 'granted' && ( + + )} + {state === 'granted' && ( + + )} +
+ {decidedSuffix && ( +
{decidedSuffix.replace(/^ · /, '')}
+ )}
) } diff --git a/frontend/src/components/BetaPending.jsx b/frontend/src/components/BetaPending.jsx index 2fcaaee..8b71ff0 100644 --- a/frontend/src/components/BetaPending.jsx +++ b/frontend/src/components/BetaPending.jsx @@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) { {isPending ? ( <>

- 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.

While you wait, the catalog on the left lists every super-draft