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

This commit is contained in:
Ben Stull
2026-05-28 03:39:25 -07:00
parent de28272914
commit 7872b921ed
14 changed files with 1204 additions and 119 deletions
+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.**