Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cfbf69e26 | |||
| 0fd8c52724 |
+15
-397
@@ -23,369 +23,6 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.17.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; no new env vars; no new secrets.**
|
||||
This release lands admin-create user with role assignment + invite
|
||||
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
|
||||
an admin can now type first name, last name, email, role, and an
|
||||
optional custom message; the framework provisions the `users` row with
|
||||
the chosen role and `permission_state='granted'` (the admin's hand is
|
||||
the grant) and sends an invite email carrying a single-use claim link.
|
||||
The invitee clicks through to `/invites/claim?token=…`, the token is
|
||||
verified and consumed, the session is established, and the user is
|
||||
routed to the passcode-set screen (per v0.10.0) on first sign-in.
|
||||
|
||||
Distinct from #12 (which ships in parallel in this wave): #12 is
|
||||
per-RFC contribution/discussion membership and uses
|
||||
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
|
||||
provisioning by an admin and uses `user_invite_tokens` (slot 019).
|
||||
Both can coexist; both surface in the same SMTP relay but with
|
||||
distinct email templates.
|
||||
|
||||
Design decisions documented inline (see `backend/app/invites.py`'s
|
||||
module docstring + the migration's header comment):
|
||||
|
||||
* **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
|
||||
entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
|
||||
Opaque chosen over JWT because revocation is then a single SQL
|
||||
UPDATE — admin-issued invites are exactly the kind of thing an
|
||||
admin should be able to yank back without rotating a signing
|
||||
key. The raw token only ever lives in the outbound email link
|
||||
and the inbound claim body.
|
||||
* **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
|
||||
in `backend/app/invites.py`). Env-var configurability is a
|
||||
§19.2 candidate; the constant is exposed as a single point of
|
||||
edit if a deployment wants to override.
|
||||
* **Immediate send, no admin-review-then-send queue.** Matches
|
||||
how the v0.9.0 beta-request admin notification works (single
|
||||
SMTP path). Admin-preview-before-send is a future enhancement.
|
||||
* **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
|
||||
a follow-up release can layer bulk on top of the same
|
||||
`POST /api/admin/users` body shape with minimal disruption.
|
||||
* **OTC skipped on first sign-in.** Per the roadmap: clicking the
|
||||
unique token in the email is itself proof of email control, so
|
||||
the claim flow signs the invitee in directly. Subsequent
|
||||
sign-ins go through the standard OTC / passcode paths.
|
||||
* **No `users` table changes.** The brief floated
|
||||
`first_sign_in_at` / `last_seen_at IS NULL` as the
|
||||
"(pending invite)" discriminator, but the existing
|
||||
`users.last_seen_at` column is NOT NULL with a `datetime('now')`
|
||||
default (migrations/001) and no `first_sign_in_at` column
|
||||
exists. Rather than land a schema migration to introduce one,
|
||||
the discriminator is the existence of an active (not-claimed,
|
||||
not-expired) row in `user_invite_tokens` joined on
|
||||
`invited_user_id`. The admin user-listing carries a
|
||||
`pending_invite` field populated via that join; on claim, the
|
||||
badge clears naturally as the invite row's `claimed_at`
|
||||
populates.
|
||||
* **Admin-create vs. self-flip refusals.** Self-invite is refused
|
||||
422 (use the role-change channel for self-edits). Duplicate
|
||||
email is refused 409 (use the existing role / grant gestures on
|
||||
the existing user). Owner-grant by a non-owner admin is refused
|
||||
422 (§6.1's owner-zero is the only owner bootstrap path; the
|
||||
sitting owner must issue the invite).
|
||||
|
||||
### Added
|
||||
|
||||
- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
|
||||
Body: `{ email, first_name?, last_name?, role, custom_message? }`.
|
||||
Provisions the `users` row with the chosen role and writes the
|
||||
`user_invite_tokens` row + dispatches the invite email + writes a
|
||||
`permission_events` row with `event_kind='user_invited'`. Returns
|
||||
`{ ok, invite_id, invited_user_id, email, role }` on success;
|
||||
surfaces the four refusals (403/422/409/422) per their distinct
|
||||
paths.
|
||||
- **`GET /api/admin/users/invites`** — admin-only. Lists active
|
||||
(not claimed, not expired) invites with the admin who created them
|
||||
joined through for display. Powers the "I sent these but they
|
||||
haven't been claimed yet" admin view.
|
||||
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
|
||||
`/auth/otc/verify` and `/auth/device-trust/start` since it shares
|
||||
the device-trust cookie helpers). Anonymous-reachable. Body:
|
||||
`{ token, trust_device? }`. Validates the token, consumes the
|
||||
invite row, signs the user in, optionally mints a device-trust
|
||||
cookie, and returns `{ ok, user, needs_passcode }`. The
|
||||
`needs_passcode` hint drives the frontend's route-to-passcode-set
|
||||
vs. route-to-home decision. Maps token-failure modes to distinct
|
||||
HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
|
||||
- **`backend/app/invites.py`** — the create + claim + list module.
|
||||
Mirrors the `device_trust.py` shape: opaque-token issuance with
|
||||
bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
|
||||
outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
|
||||
Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
|
||||
`CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
|
||||
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
|
||||
`EmailConfig.from_env()` for the SMTP plumbing + From identity;
|
||||
composes a separate template (subject "You're invited to <app> by
|
||||
<admin>"; body names the inviter, embeds the optional custom
|
||||
message in a clearly-delimited indented block if present, and
|
||||
carries the claim URL). Dev / no-SMTP path logs the envelope to
|
||||
the shared `_SENT` buffer so backend tests can assert on the
|
||||
outbound shape.
|
||||
- **Schema migration `019_user_invite_tokens.sql`** — new
|
||||
`user_invite_tokens` table (id, email, role, first_name, last_name,
|
||||
custom_message, token_hash, expires_at, created_at,
|
||||
created_by_admin_id, claimed_at, claimed_by_user_id,
|
||||
invited_user_id). Three indexes: unique on `token_hash`
|
||||
(documents the no-collision invariant); `(email, claimed_at)` for
|
||||
the "is this email already invited?" pre-check; and
|
||||
`(created_by_admin_id, created_at DESC)` for the per-admin
|
||||
invites listing. Slot 018 is reserved for the parallel #12
|
||||
release shipping in the same wave; slot 016 stays
|
||||
reserved-and-skipped per Session K's v0.9.0 integration.
|
||||
- **`frontend/src/components/InviteClaim.jsx`** — the
|
||||
`/invites/claim?token=…` landing page. Reads the token from the
|
||||
URL, renders a "Claim my account" CTA with an optional
|
||||
"trust this device for 30 days" checkbox, calls
|
||||
`POST /api/invites/claim` on submit, and routes onward
|
||||
(`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
|
||||
on success. Anonymous-reachable.
|
||||
- **"Create user + invite" affordance** on `/admin/users`
|
||||
(`frontend/src/components/Admin.jsx`). A header button opens a
|
||||
modal with email / first / last / role / custom-message inputs
|
||||
(the textarea shows a "chars left" counter against the 500-char
|
||||
ceiling). On submit, the modal calls
|
||||
`POST /api/admin/users` and refreshes the user listing.
|
||||
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
|
||||
inline with v0.17.0): `USER_INVITED` event fires from
|
||||
`CreateUserInviteModal` on successful invite-send with
|
||||
`{ target_user_id, initial_role, custom_message_chars }` — the
|
||||
OHM `invited_user_id` returned by `POST /api/admin/users`
|
||||
becomes the dashboard's binding for the future Amplitude user
|
||||
record; `custom_message_chars` is a coarse signal of admin
|
||||
effort (0 = template-only, 1+ = personalized) and carries no
|
||||
PII. `INVITE_CLAIMED` event fires from `InviteClaim.jsx` on
|
||||
successful claim with `{ invited_by_admin_id, initial_role,
|
||||
needs_passcode, trust_device }` — BUT the claim handler first
|
||||
calls `identify({ user_id, properties: { claim_method:
|
||||
'admin-invite', invited_at (setOnce), invited_by_admin_id
|
||||
(setOnce), initial_role (setOnce) } })` so the Amplitude user
|
||||
record is created with the OHM user_id from the very first
|
||||
event the invitee fires, never as an anonymous device that
|
||||
retroactively links. setOnce semantics preserve the original
|
||||
invite context even if the invitee later changes roles.
|
||||
- **"(pending invite)" badge** inline on the user-listing's
|
||||
per-row handle (rendered when the row's `pending_invite` field
|
||||
is populated by the backend's join through `user_invite_tokens`).
|
||||
Clears automatically on claim as the invite row's `claimed_at`
|
||||
populates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
|
||||
+ `EmailConfig`; adds the two new endpoints alongside the existing
|
||||
`set_role` / `set_permission` neighbors; extends `list_users` to
|
||||
join through `user_invite_tokens` and emit the `pending_invite`
|
||||
field on each row. The new `CreateUserInviteBody` pydantic model
|
||||
carries the body bounds (320-char email, 120-char first/last,
|
||||
regex-pinned role, 500-char custom_message) so malformed input
|
||||
fails at the body bound (422) instead of at the SQL layer.
|
||||
- **`backend/app/main.py`** — imports `invites as invites_mod`;
|
||||
adds the `InviteClaimBody` pydantic model alongside
|
||||
`PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
|
||||
endpoint in the OAuth router so it can reuse the
|
||||
`_set_device_trust_cookie` helper.
|
||||
- **`frontend/src/api.js`** — exports `createUserInvite()`,
|
||||
`listUserInvites()`, `claimInvite()`. Same fetch shape as the
|
||||
rest of the v0.9.0 / v0.10.0 admin neighborhood.
|
||||
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
|
||||
`/invites/claim` route alongside `/beta-pending` (both are
|
||||
anonymous-reachable auth-shape landings).
|
||||
|
||||
### Migration
|
||||
|
||||
- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
|
||||
on next backend start. Single new table with three indexes; no
|
||||
changes to existing tables.
|
||||
|
||||
### Upgrade steps (from 0.14.0)
|
||||
|
||||
- You **MUST** apply schema migration `019_user_invite_tokens.sql`.
|
||||
The migration creates a single new table with three indexes; the
|
||||
framework runs migrations automatically at process start, so no
|
||||
manual step is required beyond restarting the backend so the
|
||||
migration runner picks the file up.
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading. `frontend/package.json#version` and `VERSION` both
|
||||
move to `0.17.0`. No new env vars; no new secrets (the invite
|
||||
email rides the existing SMTP relay configured for v0.7.0's OTC
|
||||
mail).
|
||||
- You **MAY** announce the new admin-create gesture to existing
|
||||
admins. Existing user rows are unaffected — the
|
||||
`user_invite_tokens` table is empty post-migration, and the
|
||||
user-listing's new `pending_invite` field is null on every
|
||||
existing row. The bootstrap shape for the very first admin
|
||||
account stays the v0.9.0 path (DB-level role flip on an OTC-
|
||||
provisioned row); the v0.17.0 admin-create gesture works
|
||||
end-to-end once at least one admin exists.
|
||||
|
||||
|
||||
## 0.16.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration auto-applied; no operator action.** This
|
||||
release lands the owner-only invite for per-RFC PR or PR-less
|
||||
discussion (roadmap item #12). The RFC's owner can now invite
|
||||
specific users by email to one of two per-RFC roles —
|
||||
`contributor` (open PRs against the RFC AND join its discussion) or
|
||||
`discussant` (join the discussion only). Non-invited users keep
|
||||
the v0.6.0 anonymous-read contract: they can read but cannot
|
||||
write/discuss that RFC. Invitations are token-encoded in a
|
||||
transactional email; acceptance lands a per-RFC collaborator row
|
||||
and surfaces in the admin user-management page (additive on the
|
||||
existing `/api/admin/users` shape) so the platform-grant decision
|
||||
has the per-RFC context to inform it. The platform-level grant
|
||||
remains the admin's call — this release adds a per-RFC membership
|
||||
layer beneath it, not a new platform-grant path.
|
||||
|
||||
The per-RFC write gate is layered on top of the existing
|
||||
`require_contributor` (v0.8.0) gate, not in place of it: a user
|
||||
must be platform-granted AND hold an accepted per-RFC role (or
|
||||
be the RFC owner / a platform admin/owner) to write. A super-
|
||||
draft with no frontmatter owners yet (pre-§13.1 claim) falls
|
||||
through to the platform-granted contract — there's no owner to
|
||||
issue invitations, so the gate is open until one exists. This
|
||||
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
|
||||
domains and confines item #12's change to "an RFC has owners →
|
||||
those owners decide who writes."
|
||||
|
||||
### Added
|
||||
|
||||
- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
|
||||
`rfc_invitations` carries the lifecycle row (issued, accepted,
|
||||
revoked, expired) with the opaque token the email link encodes,
|
||||
the inviter, the invitee email, the role-in-RFC, and the 30-day
|
||||
expiry. `rfc_collaborators` is the accepted-invitation
|
||||
substrate — the compact (rfc, user, role) shape the write gate
|
||||
consults. Both tables are FK-cascaded against `cached_rfcs` and
|
||||
`users` per §5's cascade rules. Indexed for the owner's listing,
|
||||
the accept-by-token lookup, and the per-user read.
|
||||
- **`backend/app/api_invitations.py`** — the §17 surface. Five
|
||||
endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
|
||||
`GET /api/rfcs/{slug}/invitations` (owner's listing),
|
||||
`POST /api/rfcs/{slug}/invitations/{id}/revoke`,
|
||||
`GET /api/invitations/accept?token=…` (preview), and
|
||||
`POST /api/invitations/accept` (redeem). The email reuses
|
||||
`EmailConfig.from_env()` and the `_SENT` buffer the OTC and
|
||||
notification mailers share — transactional, no preferences
|
||||
honored, no unsubscribe footer. A failure to send does NOT
|
||||
roll back the row; the owner has the token on the listing
|
||||
surface for an out-of-band share.
|
||||
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
|
||||
reads the frontmatter `owners_json`. `is_rfc_collaborator`
|
||||
reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
|
||||
and `can_contribute_to_rfc` are the composite predicates the
|
||||
write endpoints consult (platform admin/owner OR no-owners-yet
|
||||
fall-through OR RFC owner OR per-RFC collaborator at the right
|
||||
role). `can_invite_to_rfc` is the issue-side predicate (RFC
|
||||
owner or platform admin/owner only — collaborators don't get
|
||||
invite power).
|
||||
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
|
||||
owner's surface: an email input + role picker for sending,
|
||||
and a status table for listing/revoking. Visible only to the
|
||||
RFC's owner or a platform admin/owner (the backend gates the
|
||||
endpoints regardless).
|
||||
- **`frontend/src/components/AcceptInvitation.jsx`** — the
|
||||
`/invitations/accept?token=…` landing page. Previews what the
|
||||
invitation grants, refuses on email mismatch / revoked /
|
||||
expired with a single sentence each, redirects to the RFC's
|
||||
view on accept.
|
||||
- **API client (`frontend/src/api.js`)** — five new helpers:
|
||||
`listRFCInvitations`, `createRFCInvitation`,
|
||||
`revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
|
||||
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
|
||||
inline with v0.16.0): `INVITATION_SENT` event fires from
|
||||
`InvitationsModal.jsx` on successful send with `{ rfc_slug,
|
||||
role_in_rfc }`; `INVITATION_ACCEPTED` event fires from
|
||||
`AcceptInvitation.jsx` on successful accept with the same shape —
|
||||
but the accept path first calls `identify({ user_id, properties:
|
||||
{ invited_at (setOnce), last_invited_to_rfc,
|
||||
last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the
|
||||
Amplitude user record carries the invite context from the moment
|
||||
of acceptance. No invitee email or other PII enters the event
|
||||
body — only the slug, role, and the inviter's identity (through
|
||||
the standard signed-in identify on the inviter's session).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api.py`** — registers
|
||||
`api_invitations.make_router()` alongside the existing routers.
|
||||
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
|
||||
and `POST .../discussion/threads/{thread_id}/messages` now compose
|
||||
the new `auth.can_discuss_rfc` predicate after the existing
|
||||
`require_contributor` check. A platform-granted user without a
|
||||
per-RFC discussion role on an RFC with owners gets 403 with
|
||||
"This RFC's owner has not invited you to its discussion."
|
||||
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
|
||||
and `POST .../start-edit-branch` now compose
|
||||
`auth.can_contribute_to_rfc`. Same shape: platform-granted but
|
||||
uninvited → 403.
|
||||
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
|
||||
`auth.can_contribute_to_rfc` so a user whose per-RFC role was
|
||||
revoked between branch-cut and PR-open is refused at the
|
||||
ship line.
|
||||
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
|
||||
a new `rfc_invitations` array per user (empty if none), naming
|
||||
each accepted per-RFC collaboration with the RFC slug/title,
|
||||
the role, the inviter, and the timestamp. Additive — the
|
||||
existing v0.9.0 columns are unchanged; consumers that don't
|
||||
read the new field see the legacy shape.
|
||||
- **`frontend/src/App.jsx`** — registers the
|
||||
`/invitations/accept` route (visible to anonymous + signed-in
|
||||
viewers; signed-out viewers see a sign-in prompt).
|
||||
- **`frontend/src/components/RFCView.jsx`** — additive
|
||||
"Invitations" button in the RFC header strip, visible to RFC
|
||||
owners and platform admins/owners on both super-drafts and
|
||||
active RFCs. Mounts the new modal on click.
|
||||
- **`backend/tests/test_propose_vertical.py`** — adds the
|
||||
`grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
|
||||
tests that exercise non-owner contribution can opt into the new
|
||||
invitation contract without rewriting their setup.
|
||||
- **`backend/tests/test_pr_flow_vertical.py`,
|
||||
`backend/tests/test_graduation_vertical.py`,
|
||||
`backend/tests/test_e2e_smoke.py`** — three tests that signed in
|
||||
as non-owner contributors now seed an accepted per-RFC
|
||||
collaborator row first (mirroring the production invite→accept
|
||||
dance). The test intent is unchanged; the precondition is now
|
||||
explicit.
|
||||
|
||||
### Migration
|
||||
|
||||
- **`018_rfc_invitations.sql`** — auto-applied on backend start by
|
||||
the existing `db.run_migrations()` sweep. The two new tables
|
||||
are empty at upgrade time; no existing data is touched. No
|
||||
operator gesture needed.
|
||||
|
||||
### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)
|
||||
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading so the new endpoints, the migration, the gate
|
||||
composition in `api_discussion`/`api_branches`/`api_prs`, and the
|
||||
new frontend routes/components are picked up. `frontend/package.json#version`
|
||||
and `VERSION` both move to `0.16.0`.
|
||||
- You **MUST NOT** set any new env var — there are no new secrets
|
||||
and no new overlay keys. The email path reuses the existing
|
||||
`SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
|
||||
`EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
|
||||
variables that the v0.7.0 OTC and v0.5.0 notification paths
|
||||
already require. Deployments that have those wired need no
|
||||
configuration change.
|
||||
- You **MUST NOT** apply the migration manually — the backend's
|
||||
migration runner picks up `018_rfc_invitations.sql` on next
|
||||
start. (If you've configured an external migration tool, run it
|
||||
before starting the backend; the framework's own runner is
|
||||
idempotent against already-applied migrations.)
|
||||
- You **SHOULD** inform existing RFC owners that they can now
|
||||
invite collaborators from the RFC view's header strip. RFCs
|
||||
with frontmatter owners that pre-date this release see no
|
||||
behavioral change for the owner; the change is visible to
|
||||
non-owner contributors who previously could write on any RFC
|
||||
and now must be invited first.
|
||||
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
|
||||
pre-existing per-RFC working relationships you want to
|
||||
grandfather past the v0.16.0 cutover. The `invitation_id`
|
||||
column is nullable for exactly this purpose. Production
|
||||
deployments without that history can ignore this option.
|
||||
## 0.15.0 — 2026-05-28
|
||||
|
||||
**Minor — no schema migration; one new build-time env var bound via
|
||||
@@ -414,30 +51,16 @@ nothing lands in our DB, no migration.
|
||||
### Added
|
||||
|
||||
- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
|
||||
surface: `track(name, props)`, `identify({ user_id, properties? })`,
|
||||
`setUserProperties(properties)`, `anonymize()`, the `EVENTS`
|
||||
taxonomy constant, and a `__resetForTests` helper. Internally
|
||||
lazy-imports `@amplitude/unified` and calls
|
||||
`amplitude.initAll(API_KEY, { analytics: { autocapture: true },
|
||||
sessionReplay: { sampleRate: 1 } })` only after consent is
|
||||
granted; queues pre-init calls and drains them on init resolve;
|
||||
flips `setOptOut(true)` on a granted→denied consent change (stops
|
||||
both analytics events and session replay). The wrapper subscribes
|
||||
to `onConsentChange()` so a freshly-banner-clicked "analytics on"
|
||||
flips the SDK live without a page reload.
|
||||
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
|
||||
shipped inline with v0.15.0 instead of waiting for a follow-up).
|
||||
`identify({ user_id, properties })` accepts a property bag that
|
||||
applies as an Amplitude `Identify` event with `.set()` semantics
|
||||
by default; values wrapped as `['__setOnce__', value]` apply with
|
||||
`.setOnce()` semantics (immutable after first write — for
|
||||
account-history markers like `first_sign_in_at`). The new
|
||||
`setUserProperties(properties)` exposes the same property-apply
|
||||
path for mid-session state changes (role grant/revoke, passcode
|
||||
set, device trusted) so the Amplitude record stays current without
|
||||
waiting for the next sign-in. `anonymize()` now clears both the
|
||||
user_id binding AND the pending-property cache so a subsequent
|
||||
sign-in as a different user starts with a fully fresh slate.
|
||||
surface: `track(name, props)`, `identify({ user_id })`,
|
||||
`anonymize()`, the `EVENTS` taxonomy constant, and a
|
||||
`__resetForTests` helper. Internally lazy-imports
|
||||
`@amplitude/unified` and calls `amplitude.initAll(API_KEY,
|
||||
{ analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } })`
|
||||
only after consent is granted; queues pre-init calls and drains
|
||||
them on init resolve; flips `setOptOut(true)` on a granted→denied
|
||||
consent change (stops both analytics events and session replay).
|
||||
The wrapper subscribes to `onConsentChange()` so a freshly-banner-
|
||||
clicked "analytics on" flips the SDK live without a page reload.
|
||||
- **Event taxonomy** wired into the app:
|
||||
- `Page Viewed` — fires from `App.jsx` on every route change with
|
||||
`path` (`location.pathname`); the location hook owns the firing
|
||||
@@ -462,16 +85,11 @@ nothing lands in our DB, no migration.
|
||||
- `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
|
||||
revoke action with `action ∈ { 'grant', 'revoke' }` and
|
||||
`target_user_id` (string).
|
||||
- **User binding + properties** (`App.jsx`): when `me.authenticated`
|
||||
lands and a user id is available, the wrapper's
|
||||
`identify({ user_id, properties })` is called with
|
||||
`String(viewer.id)` AND a durable property bag — `role`,
|
||||
`permission_state`, `passcode_set`, `device_trusted` (mutable;
|
||||
refresh each sign-in), plus `first_sign_in_at` and
|
||||
`account_created_at` (setOnce — immutable user-history markers).
|
||||
The sign-out gesture calls `anonymize()` before the nav. No email,
|
||||
display name, gitea_login, or other PII is passed through the SDK —
|
||||
Amplitude only sees opaque ids, enums, timestamps, booleans.
|
||||
- **User binding** (`App.jsx`): when `me.authenticated` lands and a
|
||||
user id is available, the wrapper's `identify({ user_id })` is
|
||||
called with `String(viewer.id)`. The sign-out gesture calls
|
||||
`anonymize()` before the nav. No email, display name, or other PII
|
||||
is passed through the SDK.
|
||||
- **`@amplitude/unified`** dependency added to
|
||||
`frontend/package.json` (analytics + session replay in one
|
||||
install). Lockfile updated.
|
||||
|
||||
@@ -22,7 +22,6 @@ from . import (
|
||||
api_branches,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
@@ -103,12 +102,6 @@ def make_router(
|
||||
# Contribution still requires a PR (api_prs above); this surface
|
||||
# is for discussion that does not yet warrant a branch.
|
||||
router.include_router(api_discussion.make_router())
|
||||
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
|
||||
# contribution + discussion. The RFC's owner can invite specific
|
||||
# users by email to either open PRs or join the discussion; non-
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
router.include_router(api_invitations.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
|
||||
+1
-308
@@ -11,8 +11,6 @@ The endpoints in this module:
|
||||
- `GET /api/admin/users` — list users with role + mute
|
||||
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
||||
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
||||
- `POST /api/admin/users` — v0.17.0: create user + invite
|
||||
- `GET /api/admin/users/invites` — v0.17.0: pending invites
|
||||
- `GET /api/admin/audit` — paged `actions` log
|
||||
- `GET /api/admin/permission-events` — paged `permission_events` log
|
||||
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
||||
@@ -35,9 +33,8 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db, email_invite, invites
|
||||
from . import auth, db
|
||||
from .config import Config
|
||||
from .email import EmailConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,32 +65,6 @@ class AllowlistAddBody(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
class CreateUserInviteBody(BaseModel):
|
||||
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
|
||||
|
||||
The admin types these fields on the "Create user + invite" modal on
|
||||
`/admin/users`. The email + role are required; first/last name and
|
||||
the optional custom message round out the body.
|
||||
|
||||
Bounds mirror the rest of the codebase:
|
||||
* `email`: 320 chars — RFC 5321 envelope limit, same as
|
||||
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
|
||||
* `first_name` / `last_name`: 120 chars — same as the v0.8.0
|
||||
`BetaRequestBody` capture form.
|
||||
* `role`: pydantic regex pinned to the §6.1 set so an unknown
|
||||
role fails at the body bound (422) instead of landing as a
|
||||
CHECK constraint violation in the migration.
|
||||
* `custom_message`: 500 chars — the brief calls this out as
|
||||
the max. The frontend modal shows a "remaining chars"
|
||||
counter to match.
|
||||
"""
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
first_name: str = Field(default="", max_length=120)
|
||||
last_name: str = Field(default="", max_length=120)
|
||||
role: str = Field(pattern="^(owner|admin|contributor)$")
|
||||
custom_message: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -119,17 +90,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
`permission_decided_by_login` joins the deciding admin row so
|
||||
the UI can render "granted by @ben" without a second round-trip.
|
||||
|
||||
v0.16.0 (roadmap item #12) additive: each user row now carries
|
||||
an `rfc_invitations` array — the per-RFC invitations the user
|
||||
has accepted. This is the "permission-grant requests from
|
||||
invited users" hook the roadmap text calls for: when a user
|
||||
accepts a per-RFC invite and they're not yet platform-granted,
|
||||
the admin sees "here because @ben invited them to <RFC> as
|
||||
<role>" alongside their pending row, informing (not deciding)
|
||||
the platform grant. The two write surfaces remain distinct —
|
||||
the RFC's owner controls per-RFC roles; the admin controls
|
||||
platform-grant state.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
rows = db.conn().execute(
|
||||
@@ -155,62 +115,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
u.display_name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
# v0.16.0 — per-user accepted per-RFC invitations. One query
|
||||
# over the full set, indexed bucket-by-user-id in Python so
|
||||
# the per-row attachment below is O(1). Empty array for users
|
||||
# who hold no accepted invitations.
|
||||
invitation_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
|
||||
r.title AS rfc_title,
|
||||
i.id AS invitation_id, i.invitee_email,
|
||||
ui.gitea_login AS inviter_login,
|
||||
ui.display_name AS inviter_display
|
||||
FROM rfc_collaborators c
|
||||
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
|
||||
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
|
||||
LEFT JOIN users ui ON ui.id = i.inviter_user_id
|
||||
ORDER BY c.created_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
per_user_invites: dict[int, list[dict]] = {}
|
||||
for ir in invitation_rows:
|
||||
per_user_invites.setdefault(ir["user_id"], []).append({
|
||||
"rfc_slug": ir["rfc_slug"],
|
||||
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
|
||||
"role_in_rfc": ir["role_in_rfc"],
|
||||
"invited_at": ir["created_at"],
|
||||
"invitation_id": ir["invitation_id"],
|
||||
"invitee_email": ir["invitee_email"],
|
||||
"inviter_login": ir["inviter_login"],
|
||||
"inviter_display": ir["inviter_display"],
|
||||
})
|
||||
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
|
||||
# is NULL is one of two things — a brand-new row that was just
|
||||
# provisioned (rare, and the v0.7.0 OTC verify path stamps
|
||||
# last_seen_at on the same call that creates the row), or an
|
||||
# admin-created invite-pending row (v0.17.0 — created by
|
||||
# `POST /api/admin/users`). We surface a `pending_invite_id`
|
||||
# field by joining through `user_invite_tokens` so the
|
||||
# Users tab can render a "(pending invite)" badge alongside
|
||||
# the role/state controls. Filters to invites that are
|
||||
# neither expired nor claimed — once the invitee clicks
|
||||
# through, the badge clears (and `last_seen_at` populates).
|
||||
pending_invite_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT invited_user_id, id AS invite_id, expires_at
|
||||
FROM user_invite_tokens
|
||||
WHERE claimed_at IS NULL
|
||||
AND datetime(expires_at) > datetime('now')
|
||||
"""
|
||||
).fetchall()
|
||||
pending_invites = {
|
||||
r["invited_user_id"]: {
|
||||
"invite_id": r["invite_id"],
|
||||
"expires_at": r["expires_at"],
|
||||
}
|
||||
for r in pending_invite_rows
|
||||
}
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
@@ -229,217 +133,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
"permission_decided_at": r["permission_decided_at"],
|
||||
"permission_decided_by_login": r["decided_by_login"],
|
||||
"permission_decided_by_display": r["decided_by_display"],
|
||||
# v0.16.0 additive — never null, always an array.
|
||||
"rfc_invitations": per_user_invites.get(r["id"], []),
|
||||
# v0.17.0: present iff the row is invited-but-not-
|
||||
# claimed-yet. The frontend renders a "(pending
|
||||
# invite)" badge when this is non-null.
|
||||
"pending_invite": pending_invites.get(r["id"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
|
||||
|
||||
@router.post("/api/admin/users")
|
||||
async def create_user_with_invite(
|
||||
body: CreateUserInviteBody, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""Provision a fresh `users` row with a pre-assigned role + send
|
||||
an invite email carrying a claim link.
|
||||
|
||||
Refusals:
|
||||
* `422` — the admin tries to invite their own email (no
|
||||
self-invite; symmetric to `set_permission`'s self-flip
|
||||
refusal and `set_role`'s self-downgrade refusal). Use
|
||||
the existing role-change channel for self-edits.
|
||||
* `422` — the admin tries to grant `owner` without being
|
||||
owner themselves. §6.1: owner-zero is the only owner
|
||||
bootstrap path; new owners come from a sitting owner's
|
||||
hand. A 422 here matches the message shape; a 403 would
|
||||
also be defensible, but staying with 422 keeps the
|
||||
"your input is bad" framing.
|
||||
* `409` — the email already maps to a `users` row. The
|
||||
admin should use the existing role / grant gestures on
|
||||
the existing user, not create a duplicate.
|
||||
* `422` — pydantic-level: malformed email, role outside
|
||||
the §6.1 set, custom_message over 500 chars.
|
||||
|
||||
On success:
|
||||
1. The invitee `users` row lands with the chosen role and
|
||||
`permission_state='granted'` (admin's hand is the grant)
|
||||
and `last_seen_at IS NULL` (the "(pending invite)"
|
||||
discriminator the listing surface joins through).
|
||||
2. The `user_invite_tokens` row lands with the bcrypt-
|
||||
hashed opaque token; the raw token rides only in the
|
||||
email link.
|
||||
3. The invite email dispatches with subject "You're
|
||||
invited to <app> by <admin>" and the custom message
|
||||
embedded in a clearly-delimited block if present.
|
||||
4. A `permission_events` row records the admin-create
|
||||
gesture so the §6.5 / `permissions` admin tab carries
|
||||
the audit trail alongside the existing grant/revoke
|
||||
flips.
|
||||
"""
|
||||
viewer = auth.require_admin(request)
|
||||
email_clean = body.email.strip().lower()
|
||||
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
|
||||
raise HTTPException(422, "Email looks malformed")
|
||||
|
||||
# Self-invite refusal. Compare the admin's own email
|
||||
# case-insensitively against the invite target.
|
||||
viewer_row = db.conn().execute(
|
||||
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
|
||||
).fetchone()
|
||||
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
|
||||
if viewer_email and viewer_email == email_clean:
|
||||
raise HTTPException(
|
||||
422,
|
||||
"You cannot invite yourself — use the role-change channel "
|
||||
"if you need to edit your own row",
|
||||
)
|
||||
|
||||
# Owner-grant refusal: §6.1 says only a sitting owner can mint
|
||||
# a new owner. An admin trying to invite-as-owner is refused
|
||||
# at 422; the admin should ask the owner to issue the invite,
|
||||
# or invite as `admin` and let the owner promote later.
|
||||
if body.role == "owner" and viewer.role != "owner":
|
||||
raise HTTPException(
|
||||
422,
|
||||
"Only an owner can invite a new owner — invite as admin and "
|
||||
"ask the owner to promote, or have the owner issue this invite",
|
||||
)
|
||||
|
||||
# Duplicate-email refusal. A pre-existing row (regardless of
|
||||
# permission_state) means the admin should use the existing
|
||||
# role / grant gestures, not create a parallel user.
|
||||
existing = db.conn().execute(
|
||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
|
||||
(email_clean,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
raise HTTPException(409, "A user with this email already exists")
|
||||
|
||||
# Create the invitee row + token row + send the email.
|
||||
outcome = invites.create_invite(
|
||||
email=email_clean,
|
||||
first_name=body.first_name,
|
||||
last_name=body.last_name,
|
||||
role=body.role,
|
||||
custom_message=body.custom_message,
|
||||
created_by_admin_id=viewer.user_id,
|
||||
)
|
||||
|
||||
# Audit row in permission_events so the admin Permissions tab
|
||||
# carries the gesture. The before-state is "n/a" (the row
|
||||
# did not exist); the after-state is the granted role. We
|
||||
# use a new `event_kind='user_invited'` so the existing
|
||||
# grant/revoke kinds stay scoped to their flip surface.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO permission_events
|
||||
(actor_user_id, subject_user_id, event_kind, details)
|
||||
VALUES (?, ?, 'user_invited', ?)
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
outcome.invited_user_id,
|
||||
json.dumps({
|
||||
"email": email_clean,
|
||||
"role": body.role,
|
||||
"invite_id": outcome.invite_id,
|
||||
"custom_message_chars": len(body.custom_message or ""),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
# Build the claim URL using the same APP_URL the email module
|
||||
# reads. The token rides as a query-string param to the
|
||||
# frontend route `/invites/claim?token=…`; the frontend POSTs
|
||||
# it back to `/api/invites/claim` which consumes the row.
|
||||
cfg = EmailConfig.from_env()
|
||||
from urllib.parse import urlencode
|
||||
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
|
||||
|
||||
# Fetch the inviter display so the email body can render
|
||||
# "Ben Stull (ben@example.com) has invited you to …". We
|
||||
# read off the row fresh rather than trusting the session
|
||||
# cookie's cached display_name.
|
||||
inviter_row = db.conn().execute(
|
||||
"SELECT display_name, email FROM users WHERE id = ?",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
inviter_display = (
|
||||
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
|
||||
)
|
||||
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
|
||||
|
||||
email_invite.send_invite_email(
|
||||
to_address=email_clean,
|
||||
claim_url=claim_url,
|
||||
inviter_display=inviter_display,
|
||||
inviter_email=inviter_email_for_body,
|
||||
custom_message=body.custom_message,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"invite_id": outcome.invite_id,
|
||||
"invited_user_id": outcome.invited_user_id,
|
||||
"email": email_clean,
|
||||
"role": body.role,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/users/invites")
|
||||
async def list_user_invites(request: Request) -> dict[str, Any]:
|
||||
"""List active (not claimed, not expired) admin-issued invites.
|
||||
|
||||
Powers the admin's "I sent these but they haven't been claimed
|
||||
yet" view. The frontend uses this alongside `list_users` —
|
||||
the user-listing's `pending_invite` field carries the per-row
|
||||
flag; this endpoint carries the full invite shape for a
|
||||
dedicated drill-in surface.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
rows = invites.list_pending_invites()
|
||||
# Join through to the admin display names so the surface can
|
||||
# render "invited by @ben" without a second client call.
|
||||
admin_ids = {r.created_by_admin_id for r in rows}
|
||||
admin_lookup: dict[int, dict[str, str]] = {}
|
||||
if admin_ids:
|
||||
placeholders = ",".join("?" * len(admin_ids))
|
||||
admin_rows = db.conn().execute(
|
||||
f"SELECT id, gitea_login, display_name FROM users "
|
||||
f"WHERE id IN ({placeholders})",
|
||||
tuple(admin_ids),
|
||||
).fetchall()
|
||||
admin_lookup = {
|
||||
ar["id"]: {
|
||||
"gitea_login": ar["gitea_login"] or "",
|
||||
"display_name": ar["display_name"] or "",
|
||||
}
|
||||
for ar in admin_rows
|
||||
}
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"email": r.email,
|
||||
"role": r.role,
|
||||
"first_name": r.first_name,
|
||||
"last_name": r.last_name,
|
||||
"custom_message": r.custom_message,
|
||||
"created_at": r.created_at,
|
||||
"expires_at": r.expires_at,
|
||||
"invited_user_id": r.invited_user_id,
|
||||
"created_by_admin_id": r.created_by_admin_id,
|
||||
"created_by_login": admin_lookup.get(
|
||||
r.created_by_admin_id, {}
|
||||
).get("gitea_login", ""),
|
||||
"created_by_display": admin_lookup.get(
|
||||
r.created_by_admin_id, {}
|
||||
).get("display_name", ""),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -279,15 +279,6 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
|
||||
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): cutting a contribute branch is the
|
||||
# PR-shaped write surface gate. A platform-granted user who is
|
||||
# not invited as a per-RFC contributor cannot start work that
|
||||
# only exists to land in a PR.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
@@ -340,14 +331,6 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/start-edit-branch")
|
||||
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): same per-RFC contribute gate as
|
||||
# promote-to-branch — kicking off a super-draft edit branch is
|
||||
# also PR-shaped work.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_super_draft(slug)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
|
||||
@@ -116,17 +116,6 @@ def make_router() -> APIRouter:
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
|
||||
# gated surface. The platform-level `require_contributor` above
|
||||
# ensures the user is signed in + admin-granted; this layer
|
||||
# narrows further to "is this user named for this RFC?" The
|
||||
# 403 here is structurally the v0.6.0 anon-write refusal
|
||||
# extended to non-invited platform users.
|
||||
if not auth.can_discuss_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to its discussion",
|
||||
)
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
@@ -186,12 +175,6 @@ def make_router() -> APIRouter:
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
|
||||
if not auth.can_discuss_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to its discussion",
|
||||
)
|
||||
_require_discussion_thread(slug, thread_id)
|
||||
message_id = chat_layer.append_user_message(
|
||||
thread_id=thread_id,
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
|
||||
discussion (roadmap item #12).
|
||||
|
||||
The RFC's owner can invite a specific email to one of two per-RFC roles:
|
||||
|
||||
* `contributor` — may open PRs against this RFC AND post in its
|
||||
discussion (PR-permission strictly includes discussion-permission).
|
||||
* `discussant` — may post in this RFC's PR-less discussion only.
|
||||
|
||||
Non-invited users keep the v0.6.0 anonymous-read contract: they can
|
||||
read but cannot write/discuss the RFC. Reads are not narrowed by
|
||||
this item.
|
||||
|
||||
Endpoints:
|
||||
|
||||
* `POST /api/rfcs/{slug}/invitations` — owner: create + email
|
||||
* `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted
|
||||
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke
|
||||
* `GET /api/invitations/accept` — token lookup (signed-in user)
|
||||
* `POST /api/invitations/accept` — token redeem (signed-in user)
|
||||
|
||||
The accept endpoints are deliberately platform-scoped (not nested under
|
||||
the RFC slug) because the user clicking the email link only has the
|
||||
token and may not even know the slug yet. The GET shape lets the
|
||||
frontend show a confirmation page ("RFC <X> invited you to be a
|
||||
<role> — accept?") before the POST commits the membership.
|
||||
|
||||
Permission gates (composed with `require_contributor`):
|
||||
|
||||
* Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
|
||||
platform admin/owner.
|
||||
* Accept: any platform-granted signed-in user; the gate is the
|
||||
token, not the role. The token also constrains which email the
|
||||
accept lands under — the accepting user's email must match the
|
||||
invitation's invitee_email (case-insensitive). This prevents an
|
||||
invited-but-not-the-account-holder situation from minting a
|
||||
collaborator row under the wrong identity.
|
||||
|
||||
Email shape: a single plain-text body sent via the existing SMTP path
|
||||
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
|
||||
unsubscribe footer — the email is transactional and per-invite, not a
|
||||
recurring notification. No tracking pixel.
|
||||
|
||||
Admin-page hook: when an accept lands and the user's
|
||||
`permission_state` is still `pending`, that signals to the admin's
|
||||
`/admin/users` queue that the user is here because they accepted a
|
||||
per-RFC invitation — informing (not deciding) the admin's
|
||||
platform-grant call. v0.16.0 surfaces this via additive columns on
|
||||
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
|
||||
diff in the same release) — no new endpoint, no restructure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db
|
||||
from .email import EmailConfig, _SENT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateInvitationBody(BaseModel):
|
||||
"""The owner picks an email and a role-in-RFC. No custom-message
|
||||
field — that belongs to item #16's platform-level invite surface,
|
||||
not here.
|
||||
|
||||
We validate the email with a deliberately narrow pattern rather
|
||||
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
|
||||
a dependency (and v0.7.0's OTC body does the same — see
|
||||
`OTCRequestBody`'s shape). The validation here is intentionally
|
||||
permissive: a local-part, an `@`, and a domain part with no
|
||||
whitespace. Operator-side typo catching is the job of the email
|
||||
transport; the framework only guards against obviously malformed
|
||||
input."""
|
||||
invitee_email: str = Field(min_length=3, max_length=320,
|
||||
pattern=r"^[^\s@]+@[^\s@]+$")
|
||||
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
|
||||
|
||||
|
||||
class AcceptInvitationBody(BaseModel):
|
||||
token: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 30-day TTL matches the device-trust window the framework already
|
||||
# ships (v0.11.0). A pending invitation past this is rejected at the
|
||||
# accept endpoint regardless of the row's `status` column.
|
||||
INVITATION_TTL_DAYS = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/invitations
|
||||
# The owner creates an invitation. The endpoint mints the token,
|
||||
# writes the row, and dispatches the email synchronously. A failure
|
||||
# to send the email does NOT roll back the row — the owner can
|
||||
# share the link directly out-of-band if SMTP is briefly down (the
|
||||
# `GET /api/rfcs/<slug>/invitations` response carries the token
|
||||
# for that fallback).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/invitations")
|
||||
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can invite collaborators",
|
||||
)
|
||||
|
||||
invitee_email = body.invitee_email.strip()
|
||||
role_in_rfc = body.role_in_rfc
|
||||
|
||||
# Refuse re-inviting an email that already has a pending
|
||||
# invitation on this RFC at the same role. Different-role
|
||||
# re-invite is allowed (upgrade discussant → contributor)
|
||||
# — the new row supersedes the old in the UI listing's
|
||||
# natural ordering, and acceptance of either picks up the
|
||||
# corresponding role.
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, invitee_email, role_in_rfc),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||
)
|
||||
|
||||
token = _mint_token()
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO rfc_invitations
|
||||
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
token, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
viewer.user_id,
|
||||
invitee_email,
|
||||
role_in_rfc,
|
||||
token,
|
||||
f"+{INVITATION_TTL_DAYS} days",
|
||||
),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
# Send the email — synchronous. A send failure logs and
|
||||
# returns; the row stays so the owner can recover via the
|
||||
# listing (which carries the token for an out-of-band share).
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
rfc_title=rfc["title"],
|
||||
role_in_rfc=role_in_rfc,
|
||||
token=token,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": invitation_id,
|
||||
"rfc_slug": slug,
|
||||
"invitee_email": invitee_email,
|
||||
"role_in_rfc": role_in_rfc,
|
||||
"status": "pending",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/invitations
|
||||
# The owner's listing of every invitation on the RFC, regardless
|
||||
# of status. Carries the token (for the resend / re-share path).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/invitations")
|
||||
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can view invitations",
|
||||
)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
||||
i.expires_at, i.created_at, i.accepted_at,
|
||||
i.inviter_user_id, i.accepted_by_user_id,
|
||||
u_inviter.display_name AS inviter_display,
|
||||
u_inviter.gitea_login AS inviter_login,
|
||||
u_accept.display_name AS accepted_by_display,
|
||||
u_accept.gitea_login AS accepted_by_login
|
||||
FROM rfc_invitations i
|
||||
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
|
||||
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
|
||||
WHERE i.rfc_slug = ?
|
||||
ORDER BY i.id DESC
|
||||
""",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"invitee_email": r["invitee_email"],
|
||||
"role_in_rfc": r["role_in_rfc"],
|
||||
"status": _effective_status(r),
|
||||
"token": r["token"],
|
||||
"expires_at": r["expires_at"],
|
||||
"created_at": r["created_at"],
|
||||
"accepted_at": r["accepted_at"],
|
||||
"inviter_display": r["inviter_display"],
|
||||
"inviter_login": r["inviter_login"],
|
||||
"accepted_by_display": r["accepted_by_display"],
|
||||
"accepted_by_login": r["accepted_by_login"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/invitations/<id>/revoke
|
||||
# Revokes a pending invitation. Already-accepted invitations
|
||||
# cannot be "revoked" from this surface — the corresponding
|
||||
# collaborator-removal surface is a §19.2 candidate; v0.16.0
|
||||
# only lifts the *pending* link.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
|
||||
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can revoke invitations",
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
|
||||
(invitation_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Invitation is {row['status']}; only pending invitations can be revoked",
|
||||
)
|
||||
|
||||
db.conn().execute(
|
||||
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
|
||||
(invitation_id,),
|
||||
)
|
||||
return {"ok": True, "id": invitation_id, "status": "revoked"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/invitations/accept?token=...
|
||||
# Lookup-only — returns what the invitation grants so the
|
||||
# frontend can render a confirmation page before the POST. The
|
||||
# token is required; no token, no peek.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/invitations/accept")
|
||||
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = _lookup_invitation_by_token(token)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
effective = _effective_status(row)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
|
||||
).fetchone()
|
||||
return {
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
|
||||
"role_in_rfc": row["role_in_rfc"],
|
||||
"status": effective,
|
||||
"invitee_email": row["invitee_email"],
|
||||
"email_matches_you": (viewer.email or "").strip().lower()
|
||||
== row["invitee_email"].strip().lower(),
|
||||
"expires_at": row["expires_at"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/invitations/accept
|
||||
# The accept gesture: token → collaborator row.
|
||||
#
|
||||
# Requires:
|
||||
# * an authenticated user (no token-only acceptance — we want
|
||||
# the per-user audit trail),
|
||||
# * a valid (pending, non-expired, non-revoked) invitation,
|
||||
# * the accepting user's email matches invitee_email
|
||||
# (case-insensitive).
|
||||
#
|
||||
# On success the row's status flips to 'accepted' and a
|
||||
# rfc_collaborators row is inserted (or upgraded if the user
|
||||
# already had a lower role). Idempotent: re-accepting the same
|
||||
# already-accepted invitation reads as a 200 no-op with
|
||||
# `changed=false`.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/invitations/accept")
|
||||
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = _lookup_invitation_by_token(body.token)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
|
||||
effective = _effective_status(row)
|
||||
if effective == "revoked":
|
||||
raise HTTPException(409, "Invitation was revoked")
|
||||
if effective == "expired":
|
||||
raise HTTPException(409, "Invitation has expired")
|
||||
|
||||
# Email match — case-insensitive. Empty viewer email cannot
|
||||
# accept (an OAuth-only user with no captured email shape).
|
||||
viewer_email = (viewer.email or "").strip().lower()
|
||||
invitee_email = row["invitee_email"].strip().lower()
|
||||
if not viewer_email or viewer_email != invitee_email:
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This invitation was sent to a different email; sign in with that address",
|
||||
)
|
||||
|
||||
if effective == "accepted":
|
||||
# Idempotent re-accept — surface the existing collaborator
|
||||
# row without writing anything new.
|
||||
collab = db.conn().execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||
(row["rfc_slug"], viewer.user_id),
|
||||
).fetchone()
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": False,
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
|
||||
}
|
||||
|
||||
# First-time accept. Flip the invitation; upsert the
|
||||
# collaborator. We do the upsert with ON CONFLICT so a
|
||||
# user who already held a lower role gets upgraded, never
|
||||
# downgraded (the MAX-style precedence is contributor >
|
||||
# discussant; lower roles never overwrite higher).
|
||||
with db.tx() as c:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE rfc_invitations
|
||||
SET status = 'accepted',
|
||||
accepted_at = datetime('now'),
|
||||
accepted_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, row["id"]),
|
||||
)
|
||||
existing = c.execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||
(row["rfc_slug"], viewer.user_id),
|
||||
).fetchone()
|
||||
target_role = _max_role(
|
||||
existing["role_in_rfc"] if existing else None,
|
||||
row["role_in_rfc"],
|
||||
)
|
||||
if existing is None:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO rfc_collaborators
|
||||
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
|
||||
)
|
||||
elif existing["role_in_rfc"] != target_role:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE rfc_collaborators
|
||||
SET role_in_rfc = ?, invitation_id = ?
|
||||
WHERE rfc_slug = ? AND user_id = ?
|
||||
""",
|
||||
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": True,
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"role_in_rfc": target_role,
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
"""The invitation surface only operates on a known, non-withdrawn
|
||||
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
|
||||
discussion endpoints' `_require_rfc_readable` shape."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
return row
|
||||
|
||||
|
||||
def _lookup_invitation_by_token(token: str):
|
||||
return db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
status, token, expires_at, created_at, accepted_at,
|
||||
accepted_by_user_id
|
||||
FROM rfc_invitations
|
||||
WHERE token = ?
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _effective_status(row) -> str:
|
||||
"""The row's column status is the authoritative truth except for
|
||||
`expired` — that is derived from `expires_at` at read time so an
|
||||
unattended cron isn't required to flip rows. A revoked-then-
|
||||
expired row reads as `revoked` (the explicit gesture wins)."""
|
||||
column_status = row["status"]
|
||||
if column_status != "pending":
|
||||
return column_status
|
||||
# Compare via SQL so the comparison is in sqlite-time, matching the
|
||||
# `datetime('now')` insert. A simpler same-process comparison would
|
||||
# work too, but routing through the DB keeps the timezone handling
|
||||
# consistent with the inserts.
|
||||
is_past = db.conn().execute(
|
||||
"SELECT datetime(?) <= datetime('now') AS past",
|
||||
(row["expires_at"],),
|
||||
).fetchone()["past"]
|
||||
return "expired" if is_past else "pending"
|
||||
|
||||
|
||||
def _mint_token() -> str:
|
||||
"""A 256-bit URL-safe token. The token shape is opaque to the
|
||||
consumer; the email link encodes it as a query param."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _max_role(existing: str | None, new: str) -> str:
|
||||
"""contributor strictly dominates discussant. A re-accept that
|
||||
would lower the role is a no-op (the existing role survives)."""
|
||||
precedence = {"discussant": 0, "contributor": 1}
|
||||
if existing is None:
|
||||
return new
|
||||
if precedence.get(new, 0) > precedence.get(existing, 0):
|
||||
return new
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email dispatch — transactional, no preferences honored
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _send_invitation_email(
|
||||
*,
|
||||
to_address: str,
|
||||
inviter_display: str,
|
||||
rfc_title: str,
|
||||
role_in_rfc: str,
|
||||
token: str,
|
||||
) -> bool:
|
||||
"""Compose and send the invitation email.
|
||||
|
||||
Like `email_otc.send_otc_email`, this writes its own envelope and
|
||||
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
|
||||
`_SENT` buffer is appended either way so integration tests can
|
||||
assert on the outbound shape without a real SMTP server.
|
||||
|
||||
Returns True on the happy path / dev fallback; False on SMTP
|
||||
failure. The caller does not roll back the invitation row on
|
||||
failure — the owner has the token in the create response and on
|
||||
the listing surface for an out-of-band share.
|
||||
"""
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
|
||||
role_label = (
|
||||
"open PRs against the RFC and join its discussion"
|
||||
if role_in_rfc == "contributor"
|
||||
else "join the RFC's discussion"
|
||||
)
|
||||
link = f"{cfg.app_url}/invitations/accept?token={token}"
|
||||
body = (
|
||||
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
|
||||
f"This invitation lets you {role_label}.\n\n"
|
||||
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
|
||||
f" {link}\n\n"
|
||||
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
|
||||
f"this, you can safely ignore the email.\n\n"
|
||||
f"---\n"
|
||||
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "rfc_invitation",
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
if not cfg.enabled:
|
||||
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback — surface the link at INFO so the operator can
|
||||
# complete an accept flow without an SMTP relay.
|
||||
log.info(
|
||||
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
|
||||
to_address, rfc_title, role_in_rfc, link,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = envelope["from"]
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||
try:
|
||||
if cfg.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if cfg.smtp_user:
|
||||
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("invitation email send failed: to=%s", to_address)
|
||||
return False
|
||||
@@ -112,17 +112,6 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
|
||||
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): opening a PR is the canonical PR-shaped
|
||||
# write — the gate fires here even though the branch-cutting
|
||||
# entry points also gate, since a user with prior branch access
|
||||
# who's since had their per-RFC role revoked shouldn't be able
|
||||
# to ship the PR. The branch-creation gate is the kickoff
|
||||
# refusal; this one is the post-work refusal.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
if branch == "main":
|
||||
raise HTTPException(409, "PRs open from non-main branches")
|
||||
|
||||
@@ -290,159 +290,5 @@ def require_admin(request: Request) -> SessionUser:
|
||||
return user
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
# endpoints that an RFC's owner can selectively open up. The "discussion"
|
||||
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
|
||||
# to admit users who are either platform-privileged (admin, RFC owner)
|
||||
# OR who hold an explicit invitation-accepted per-RFC role.
|
||||
#
|
||||
# The platform gate still fires first: a user whose
|
||||
# `permission_state != 'granted'` cannot write anywhere, invitation or
|
||||
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
|
||||
# *within* the granted-platform-user population. (Accepting an
|
||||
# invitation as a pending user surfaces in the admin-page hook per
|
||||
# the roadmap text; the platform grant remains the admin's decision.)
|
||||
|
||||
|
||||
def _rfc_owners_set(rfc_slug: str) -> set[str]:
|
||||
"""The gitea_logins named in the RFC's frontmatter owners array.
|
||||
|
||||
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
|
||||
isn't cached (the caller's earlier `_require_rfc_readable` will
|
||||
already have rejected that case in practice).
|
||||
"""
|
||||
import json as _json
|
||||
row = db.conn().execute(
|
||||
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return set()
|
||||
try:
|
||||
return set(_json.loads(row["owners_json"] or "[]"))
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""True iff the user is named in the RFC's frontmatter `owners`
|
||||
list. The platform-level admin/owner check is separate; per §6.1 an
|
||||
app admin/owner has all per-RFC capabilities by construction, but
|
||||
this predicate is intentionally narrow — it answers "is this
|
||||
person on the RFC's owners line?" and nothing more.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
return user.gitea_login in _rfc_owners_set(rfc_slug)
|
||||
|
||||
|
||||
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
|
||||
"""True iff the user has an accepted per-RFC collaborator row.
|
||||
|
||||
`role_in_rfc`:
|
||||
* None — any role qualifies (the discussion-write check uses this
|
||||
shape: contributor strictly includes discussant).
|
||||
* 'contributor' — only the contributor role qualifies (the PR-write
|
||||
check uses this shape).
|
||||
* 'discussant' — only the discussant role qualifies (not used by
|
||||
v0.16.0 endpoints; included for symmetry).
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if role_in_rfc is None:
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
|
||||
(rfc_slug, user.user_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
|
||||
(rfc_slug, user.user_id, role_in_rfc),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
|
||||
|
||||
True if ANY of:
|
||||
* platform admin/owner (the §6.1 maximal-capability path),
|
||||
* the RFC has no frontmatter owners yet (the gate is open
|
||||
until an owner exists to set it — relevant for super-drafts
|
||||
pre-§13.1 claim),
|
||||
* RFC owner (frontmatter `owners` membership),
|
||||
* accepted per-RFC collaborator at any role (contributor strictly
|
||||
includes discussant).
|
||||
|
||||
Returns False for anonymous viewers and for users whose
|
||||
`permission_state != 'granted'` — the platform-level gate must hold
|
||||
before any per-RFC layer can apply. The platform gate is also
|
||||
enforced earlier in the request via `require_contributor`; the
|
||||
helper here is defensive so callers that compose it with
|
||||
`current_user` directly still respect the gate.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# No owner to gate the invite-list — fall through to the
|
||||
# platform-granted contract. The first §13.1 claim engages
|
||||
# the gate; before that, anyone platform-granted can
|
||||
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
|
||||
|
||||
|
||||
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — admit to PR-shaped writes on this RFC.
|
||||
|
||||
True if ANY of:
|
||||
* platform admin/owner,
|
||||
* the RFC has no frontmatter owners yet (gate open until an
|
||||
owner exists),
|
||||
* RFC owner,
|
||||
* accepted per-RFC collaborator at role 'contributor' (a
|
||||
'discussant' row is NOT sufficient — PRs are the
|
||||
higher-privilege surface).
|
||||
|
||||
Same `permission_state` and anonymous-viewer refusals as
|
||||
`can_discuss_rfc`.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# Same fall-through as can_discuss_rfc: until an owner exists,
|
||||
# the gate is open.
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
|
||||
|
||||
|
||||
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
|
||||
can issue invitations. Per-RFC collaborators do not get the
|
||||
invite-others power; that stays with the RFC's owner."""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
def new_state() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
|
||||
|
||||
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
|
||||
create-with-invite, this module composes and sends the invite envelope.
|
||||
|
||||
Structurally distinct from:
|
||||
|
||||
* `email_otc.py` (v0.7.0) — that one carries a credential the user
|
||||
just requested; this one carries a credential the admin is sending
|
||||
unsolicited.
|
||||
* `email.py` (§15.4 notification mailer) — that one is inbox-driven,
|
||||
bundled, with category opt-outs; this one is a single transactional
|
||||
outbound to a person who does not yet have an inbox.
|
||||
* v0.9.0's `new_beta_request` admin notification — that one is
|
||||
invitee-to-admin (an existing pending user asking to be let in);
|
||||
this one is admin-to-invitee (an admin reaching out to seed access).
|
||||
|
||||
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
||||
and the From identity, but writes its own envelope. In dev (no
|
||||
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
||||
the same `_SENT` buffer the notification mailer uses, so the
|
||||
integration tests can assert on the outbound shape without standing
|
||||
up an SMTP server.
|
||||
|
||||
The send is synchronous. The admin endpoint returns 200 on the
|
||||
create-row half regardless of send outcome — a transient SMTP
|
||||
failure should not roll back the invite (an admin can re-send via a
|
||||
future "resend invite" gesture, deferred to a follow-up release).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_invite_email(
|
||||
*,
|
||||
to_address: str,
|
||||
claim_url: str,
|
||||
inviter_display: str,
|
||||
inviter_email: str,
|
||||
custom_message: str = "",
|
||||
) -> bool:
|
||||
"""Compose and send the admin-invite email. Returns True on the
|
||||
happy path; False on SMTP failure. The notifier-side buffer
|
||||
`_SENT` is appended either way so tests can assert on content.
|
||||
|
||||
The body names the inviting admin, embeds the optional custom
|
||||
message in a clearly delimited block if present, and ships the
|
||||
claim link. The subject names the inviter so the recipient can
|
||||
recognize the sender at a glance in their inbox preview.
|
||||
"""
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = _subject(inviter_display, cfg)
|
||||
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "invite",
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
if not cfg.enabled:
|
||||
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback: surface the claim URL at INFO so the operator can
|
||||
# complete a claim flow without an SMTP relay. In production
|
||||
# SMTP_HOST is always set per OHM's overlay.
|
||||
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = envelope["from"]
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||
try:
|
||||
if cfg.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if cfg.smtp_user:
|
||||
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("invite email send failed: to=%s", to_address)
|
||||
return False
|
||||
|
||||
|
||||
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
|
||||
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
|
||||
inviter = inviter_display or "an admin"
|
||||
return f"You're invited to {cfg.from_name} by {inviter}"
|
||||
|
||||
|
||||
def _body(
|
||||
claim_url: str,
|
||||
inviter_display: str,
|
||||
inviter_email: str,
|
||||
custom_message: str,
|
||||
cfg: EmailConfig,
|
||||
) -> str:
|
||||
inviter = inviter_display or "An admin"
|
||||
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
|
||||
message_block = ""
|
||||
if custom_message.strip():
|
||||
# Indent the custom message so it reads as a clearly-delimited
|
||||
# quote rather than running together with the framework's
|
||||
# framing text. Per-line indent keeps multi-line messages
|
||||
# visually grouped in plain-text mail clients.
|
||||
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
|
||||
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
|
||||
|
||||
return (
|
||||
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
|
||||
f"{message_block}\n"
|
||||
f"Click the link below to claim your account and sign in.\n"
|
||||
f"This link is single-use and expires in 7 days.\n\n"
|
||||
f" {claim_url}\n\n"
|
||||
f"If you weren't expecting this invitation, you can ignore this\n"
|
||||
f"email — no account becomes active until you click the link.\n\n"
|
||||
f"---\n"
|
||||
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||
)
|
||||
@@ -1,425 +0,0 @@
|
||||
"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||
|
||||
Distinguishes from the v0.8.0 self-serve beta-access flow:
|
||||
|
||||
* **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
|
||||
a fresh `users` row lands in `permission_state='pending'`; an admin
|
||||
grants or revokes via the v0.9.0 user-management page.
|
||||
|
||||
* **Admin-create (v0.17.0)** — an admin types first/last/email/role
|
||||
*before* the invitee has signed in. The framework provisions the
|
||||
`users` row with the chosen role and `permission_state='granted'`
|
||||
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
|
||||
"invited but not yet arrived" discriminator. An invite-token row
|
||||
lands in `user_invite_tokens`; the admin's chosen `custom_message`
|
||||
(if any) rides in the email body alongside the claim link.
|
||||
|
||||
* **Claim flow** — the invitee clicks the link, which lands them at
|
||||
`/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
|
||||
the token. The framework verifies the token (not expired, not
|
||||
claimed, hash matches), marks the row claimed, signs the user in,
|
||||
and returns a payload telling the frontend whether to route to
|
||||
passcode-set (if v0.10.0 passcode flow is in play and the user has
|
||||
no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
|
||||
unique token in the email is itself proof of email control, per
|
||||
the roadmap. This is the intentional UX shortcut for first
|
||||
sign-in; subsequent sign-ins use the standard OTC / passcode
|
||||
paths.
|
||||
|
||||
The shape:
|
||||
|
||||
* `create_invite(...)` — provision the invitee `users` row + the
|
||||
`user_invite_tokens` row, return the raw token for the admin
|
||||
endpoint to put in the outbound email link.
|
||||
* `claim(raw_token)` — validate the token, mark it claimed, return
|
||||
the `SessionUser` the endpoint signs in. Distinguishes the failure
|
||||
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
|
||||
can map them to HTTP 410 vs HTTP 404 cleanly.
|
||||
* `list_pending_invites()` — return active invites for the admin
|
||||
listing surface. Filters out claimed + expired rows so the surface
|
||||
only shows live invites.
|
||||
|
||||
Token shape: opaque DB token (256 bits of CSPRNG entropy via
|
||||
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
|
||||
over JWT because revocation is then a single SQL UPDATE — a JWT
|
||||
would be stateless but harder to invalidate, and admin-issued
|
||||
invites are exactly the kind of thing an admin should be able to
|
||||
yank back. The raw token only ever lives in the outbound email link
|
||||
and the inbound claim body; server-side storage is the hash.
|
||||
|
||||
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
|
||||
configurability is a §19.2 candidate — the constant is exposed
|
||||
here as a single point of edit if a deployment wants to override.
|
||||
|
||||
The 500-char ceiling on `custom_message` is enforced at the
|
||||
Pydantic body level in `api_admin.py`; this module trusts what
|
||||
the endpoint hands it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INVITE_TOKEN_TTL_DAYS = 7
|
||||
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
|
||||
# URL-safe characters; the bcrypt hash is what's stored, so the raw
|
||||
# token only ever lives in the outbound email link.
|
||||
TOKEN_BYTES = 32
|
||||
# Free-text ceiling for the admin's optional custom message. Matched
|
||||
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
|
||||
# bound is documented in one place.
|
||||
CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token + hash helpers (mirror device_trust.py shape)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check(token: str, token_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateOutcome:
|
||||
"""The shape returned from `create_invite`.
|
||||
|
||||
`raw_token` is what the admin endpoint puts in the outbound email
|
||||
link; it never appears in storage. `invite_id` is the surrogate
|
||||
key for the admin's "invites I've sent" listing. `invited_user_id`
|
||||
is the freshly-provisioned `users` row id so the admin surface can
|
||||
join through to the user-management page.
|
||||
"""
|
||||
raw_token: str
|
||||
invite_id: int
|
||||
invited_user_id: int
|
||||
|
||||
|
||||
def create_invite(
|
||||
*,
|
||||
email: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
role: str,
|
||||
custom_message: str,
|
||||
created_by_admin_id: int,
|
||||
) -> CreateOutcome:
|
||||
"""Provision the invitee `users` row + the `user_invite_tokens` row.
|
||||
|
||||
Caller (`api_admin.py`) is responsible for the admin-only auth check,
|
||||
the self-email refusal (422), and the duplicate-email refusal (409).
|
||||
This function trusts what it's handed and writes both rows
|
||||
transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
|
||||
helpers follow the same separation-of-concerns pattern.
|
||||
|
||||
The invitee `users` row is provisioned with:
|
||||
* `permission_state='granted'` — the admin's hand is the grant;
|
||||
the v0.8.0 self-serve `pending` queue is for the other path.
|
||||
* `last_seen_at = NULL` — the discriminator for "invited but
|
||||
not yet arrived" per the §16 / roadmap design. Every sign-in
|
||||
path stamps `last_seen_at` to now, so a NULL value means the
|
||||
invited user has not clicked through yet.
|
||||
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
|
||||
OTC-provisioned user; the OAuth identity is grandfathered if
|
||||
the user ever lands through that path.
|
||||
* `display_name` defaults to "<first> <last>" (or local-part of
|
||||
email if both are empty) so the user-management page reads a
|
||||
sensible label before the user has signed in.
|
||||
* `first_name` / `last_name` / `beta_request_reason` — the
|
||||
first two from the admin's typed values; reason stays blank
|
||||
(this user did not self-request access).
|
||||
"""
|
||||
email_clean = email.strip()
|
||||
first_clean = (first_name or "").strip()
|
||||
last_clean = (last_name or "").strip()
|
||||
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
|
||||
if not display:
|
||||
display = email_clean.split("@", 1)[0] or email_clean
|
||||
|
||||
# 1. Provision the invitee users row. The grant is the admin's
|
||||
# hand; no permission_events row is necessary for the grant itself
|
||||
# (we are not transitioning from pending → granted, we are landing
|
||||
# a fresh row directly into granted).
|
||||
#
|
||||
# Note on the "pending invite" discriminator: the brief floated
|
||||
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
|
||||
# admin user-management page reads off the row to render the
|
||||
# "(pending invite)" badge. The schema didn't cooperate — the
|
||||
# existing `users.last_seen_at` column is NOT NULL with a
|
||||
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
|
||||
# and there is no `first_sign_in_at` column. Rather than introduce
|
||||
# a schema migration to add one (the brief explicitly said "likely
|
||||
# no `users` table changes"), the discriminator is the existence of
|
||||
# an active row in `user_invite_tokens` joined on `invited_user_id`.
|
||||
# The admin listing's `pending_invite` field joins through that
|
||||
# table; the claim flow stamps `claimed_at` on the invite row,
|
||||
# which clears the badge naturally. This shape keeps the
|
||||
# discriminator scoped to the v0.17.0 surface and avoids
|
||||
# double-tracking against an existing column.
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
gitea_id, gitea_login, email, display_name, avatar_url,
|
||||
role, permission_state, first_name, last_name
|
||||
)
|
||||
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
|
||||
""",
|
||||
(email_clean, display, role, first_clean, last_clean),
|
||||
)
|
||||
invited_user_id = cur.lastrowid
|
||||
|
||||
# 2. Mint the token, hash it, write the invite row.
|
||||
raw = _new_token()
|
||||
h = _hash(raw)
|
||||
cur = db.conn().execute(
|
||||
f"""
|
||||
INSERT INTO user_invite_tokens (
|
||||
email, role, first_name, last_name, custom_message,
|
||||
token_hash, expires_at, created_by_admin_id, invited_user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
|
||||
""",
|
||||
(
|
||||
email_clean,
|
||||
role,
|
||||
first_clean,
|
||||
last_clean,
|
||||
(custom_message or "").strip(),
|
||||
h,
|
||||
created_by_admin_id,
|
||||
invited_user_id,
|
||||
),
|
||||
)
|
||||
invite_id = cur.lastrowid
|
||||
return CreateOutcome(
|
||||
raw_token=raw,
|
||||
invite_id=invite_id,
|
||||
invited_user_id=invited_user_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaimOutcome:
|
||||
"""The result of `claim`.
|
||||
|
||||
`user` is populated only on success. `reason` distinguishes the
|
||||
failure modes so the endpoint can return distinct HTTP statuses
|
||||
(HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
|
||||
unknown/invalid — the request shape is wrong).
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
|
||||
invite_id: int | None = None
|
||||
|
||||
|
||||
def claim(raw_token: str) -> ClaimOutcome:
|
||||
"""Validate the presented token and consume it.
|
||||
|
||||
Walks the active invite rows looking for a bcrypt hash match.
|
||||
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
|
||||
cannot SELECT by hash, but the set is small (a deployment's
|
||||
outstanding invites at any moment) and bcrypt is cheap on the
|
||||
order of milliseconds.
|
||||
|
||||
On a hit:
|
||||
* Mark the row claimed (stamp `claimed_at = now`,
|
||||
`claimed_by_user_id = invited_user_id` — the admin's
|
||||
pre-provisioned row is the claimant).
|
||||
* Stamp `last_seen_at = now` on the user row so the v0.9.0
|
||||
admin user-management page no longer shows "(pending invite)".
|
||||
* Return a populated `SessionUser` for the endpoint to sign in.
|
||||
|
||||
On a miss:
|
||||
* `unknown` — no row matched. The token may have been forged or
|
||||
the invite was admin-revoked.
|
||||
* `expired` — row matched but `expires_at` is in the past.
|
||||
* `claimed` — row matched but `claimed_at` is non-NULL. The
|
||||
token was already consumed; the user must contact the admin
|
||||
for a fresh invite.
|
||||
* `invalid` — the token string itself was empty or unparseable.
|
||||
"""
|
||||
raw = (raw_token or "").strip()
|
||||
if not raw:
|
||||
return ClaimOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
|
||||
FROM user_invite_tokens
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check(raw, row["token_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
return ClaimOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
if matched["claimed_at"] is not None:
|
||||
return ClaimOutcome(
|
||||
ok=False, user=None, reason="claimed", invite_id=matched["id"],
|
||||
)
|
||||
|
||||
expired = db.conn().execute(
|
||||
"SELECT datetime(?) < datetime('now') AS expired",
|
||||
(matched["expires_at"],),
|
||||
).fetchone()["expired"]
|
||||
if expired:
|
||||
return ClaimOutcome(
|
||||
ok=False, user=None, reason="expired", invite_id=matched["id"],
|
||||
)
|
||||
|
||||
# Consume the row before signing in so a parallel claim of the same
|
||||
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
|
||||
# before-provision shape.)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE user_invite_tokens
|
||||
SET claimed_at = datetime('now'),
|
||||
claimed_by_user_id = invited_user_id
|
||||
WHERE id = ?
|
||||
""",
|
||||
(matched["id"],),
|
||||
)
|
||||
# Stamp last_seen_at on the user row so the user's activity stamp
|
||||
# is current after the claim (mirroring otc.verify_code's
|
||||
# last-seen update on the provision path). The "(pending invite)"
|
||||
# badge's clear is driven by the invite row's `claimed_at`
|
||||
# transition above; this update is for the general user-listing's
|
||||
# recency ordering.
|
||||
db.conn().execute(
|
||||
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||
(matched["invited_user_id"],),
|
||||
)
|
||||
|
||||
user_row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
|
||||
role, permission_state
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(matched["invited_user_id"],),
|
||||
).fetchone()
|
||||
if user_row is None:
|
||||
# The invitee user row was deleted between create_invite and
|
||||
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
|
||||
# cascade would drop the invite row too — be defensive anyway).
|
||||
return ClaimOutcome(
|
||||
ok=False, user=None, reason="unknown", invite_id=matched["id"],
|
||||
)
|
||||
|
||||
return ClaimOutcome(
|
||||
ok=True,
|
||||
user=SessionUser(
|
||||
user_id=user_row["id"],
|
||||
gitea_id=user_row["gitea_id"] or 0,
|
||||
gitea_login=user_row["gitea_login"] or "",
|
||||
display_name=user_row["display_name"],
|
||||
email=user_row["email"] or "",
|
||||
avatar_url=user_row["avatar_url"] or "",
|
||||
role=user_row["role"],
|
||||
permission_state=user_row["permission_state"] or "granted",
|
||||
),
|
||||
reason="ok",
|
||||
invite_id=matched["id"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List pending invites — for the admin's review surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingInviteRow:
|
||||
"""The shape the `GET /api/admin/users/invites` endpoint returns.
|
||||
|
||||
Note the absence of `token_hash` — the hash is structurally private,
|
||||
and the surface has no use for it. The raw token is also not on
|
||||
the listing; it lives only in the email link.
|
||||
"""
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
custom_message: str
|
||||
created_at: str
|
||||
expires_at: str
|
||||
created_by_admin_id: int
|
||||
invited_user_id: int
|
||||
|
||||
|
||||
def list_pending_invites() -> list[PendingInviteRow]:
|
||||
"""Active invites (not claimed, not expired), freshest first.
|
||||
|
||||
The admin's "I sent these but they haven't been claimed yet" view.
|
||||
Filters mirror the `device_trust.list_for_user` shape: the surface
|
||||
only shows live records the framework would actually accept on a
|
||||
presented token.
|
||||
"""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, email, role, first_name, last_name, custom_message,
|
||||
created_at, expires_at, created_by_admin_id, invited_user_id
|
||||
FROM user_invite_tokens
|
||||
WHERE claimed_at IS NULL
|
||||
AND datetime(expires_at) > datetime('now')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [
|
||||
PendingInviteRow(
|
||||
id=row["id"],
|
||||
email=row["email"],
|
||||
role=row["role"],
|
||||
first_name=row["first_name"] or "",
|
||||
last_name=row["last_name"] or "",
|
||||
custom_message=row["custom_message"] or "",
|
||||
created_at=row["created_at"],
|
||||
expires_at=row["expires_at"],
|
||||
created_by_admin_id=row["created_by_admin_id"],
|
||||
invited_user_id=row["invited_user_id"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -24,7 +24,6 @@ from . import (
|
||||
digest,
|
||||
email_otc,
|
||||
hygiene,
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
@@ -73,25 +72,6 @@ class PasscodeVerifyBody(BaseModel):
|
||||
trust_device: bool = False
|
||||
|
||||
|
||||
class InviteClaimBody(BaseModel):
|
||||
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
|
||||
|
||||
The frontend `/invites/claim?token=…` page reads the token from
|
||||
the URL and POSTs it here. The body bound matches the
|
||||
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
|
||||
the upper bound stays generous in case `TOKEN_BYTES` is ever
|
||||
raised. The token-shape is opaque to this layer — `invites.claim`
|
||||
bcrypt-checks it against the active candidate set.
|
||||
"""
|
||||
token: str = Field(min_length=1, max_length=512)
|
||||
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
|
||||
# is bundled here so the invitee can land trusted on first sign-in
|
||||
# without an extra roundtrip. Defaults to false so the gesture is
|
||||
# explicit (the frontend modal renders a checkbox alongside the
|
||||
# claim CTA).
|
||||
trust_device: bool = False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
config = load_config()
|
||||
@@ -402,95 +382,6 @@ def _oauth_router(config) -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
||||
#
|
||||
# The admin-create surface lives at POST /api/admin/users (see
|
||||
# api_admin.py); this endpoint is the corresponding claim path the
|
||||
# invitee hits when they click the link in their invite email.
|
||||
# The frontend route `/invites/claim?token=…` reads the token from
|
||||
# the URL and POSTs it here.
|
||||
#
|
||||
# The claim itself is the first-sign-in for the invitee: clicking
|
||||
# the unique token in the email is proof of email control per the
|
||||
# roadmap, so this endpoint skips the OTC step entirely on first
|
||||
# sign-in. The session cookie lands; the response tells the
|
||||
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
||||
# flow is in play and the user has not yet set a passcode) or to
|
||||
# home.
|
||||
#
|
||||
# The endpoint is anonymous-reachable: the entire point is to
|
||||
# establish the session, so we do not gate it on `require_user`.
|
||||
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
||||
# contract (the body's `trust_device` flag, when true, mints a
|
||||
# fresh device-trust row on the same response so the invitee
|
||||
# lands trusted on their first device).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/invites/claim")
|
||||
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
||||
result = invites_mod.claim(body.token)
|
||||
if result.reason == "expired":
|
||||
# The token's TTL window passed without a claim. HTTP 410
|
||||
# (Gone) so the frontend can render a "this invite has
|
||||
# expired — please contact the admin for a fresh one"
|
||||
# message distinct from the generic invalid-token shape.
|
||||
raise HTTPException(410, "This invite has expired")
|
||||
if result.reason == "claimed":
|
||||
# The token was already consumed. HTTP 410 for the same
|
||||
# reason — the row is dead either way.
|
||||
raise HTTPException(410, "This invite has already been claimed")
|
||||
if not result.ok or result.user is None:
|
||||
# 'unknown' / 'invalid' — the token does not match any
|
||||
# active invite row. HTTP 400 so it reads distinct from
|
||||
# the dead-token shape above.
|
||||
raise HTTPException(400, "Invalid invite token")
|
||||
|
||||
# Establish the session. From here on the invitee is signed
|
||||
# in as the pre-provisioned user row carrying their
|
||||
# pre-assigned role.
|
||||
auth.store_session(request, result.user)
|
||||
|
||||
# v0.11.0 — opt-in device trust on the claim response. Same
|
||||
# contract as OTC/passcode verify: when the body's flag is
|
||||
# true, the server mints a fresh device-trust row and sets
|
||||
# the long-lived cookie, so the invitee skips the email step
|
||||
# on subsequent visits to the same browser.
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
|
||||
# Has the user already set a passcode? (Could only happen via
|
||||
# an admin pre-population path that doesn't exist yet, but
|
||||
# the response shape mirrors `/api/auth/me` so the frontend
|
||||
# can read it without a second call.) If `needs_passcode` is
|
||||
# true and v0.10.0 passcode flow is in play, the frontend
|
||||
# routes to /settings/notifications#sign-in to set a passcode
|
||||
# immediately; otherwise it routes to /.
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash FROM users WHERE id = ?",
|
||||
(result.user.user_id,),
|
||||
).fetchone()
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": result.user.user_id,
|
||||
"display_name": result.user.display_name,
|
||||
"email": result.user.email,
|
||||
"role": result.user.role,
|
||||
"permission_state": result.user.permission_state,
|
||||
},
|
||||
# Roadmap §16: the claim flow skips OTC entirely; the
|
||||
# natural next step is passcode-set (so the invitee can
|
||||
# sign back in without needing an email roundtrip on their
|
||||
# second visit). The frontend uses this hint to decide
|
||||
# whether to route to the passcode-set screen or to home.
|
||||
"needs_passcode": not has_passcode,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||
#
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution +
|
||||
-- discussion (roadmap item #12).
|
||||
--
|
||||
-- Distinct from a platform-level grant (`users.permission_state`,
|
||||
-- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner
|
||||
-- invites a specific email to either open PRs against that RFC
|
||||
-- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less
|
||||
-- discussion only (`role_in_rfc='discussant'`). Non-invited users keep
|
||||
-- the v0.6.0 anonymous-read contract — they can read but cannot
|
||||
-- write/discuss that specific RFC.
|
||||
--
|
||||
-- Coordinates with item #16's parallel work this wave: that item
|
||||
-- adds platform-wide invitation tokens; this one adds per-RFC
|
||||
-- collaboration rows. To avoid table-name + concept collisions the
|
||||
-- two surfaces are scoped distinctly — this migration owns slot 018
|
||||
-- and names everything `rfc_*` (RFC-scoped); #16 will use a later
|
||||
-- slot and name its tables under a different prefix (`invite_tokens`
|
||||
-- or similar) at the user/platform level.
|
||||
--
|
||||
-- Tables in this migration:
|
||||
--
|
||||
-- * `rfc_invitations` — one row per (rfc, invitee_email) invite
|
||||
-- issued by the RFC's owner. Carries the role-in-RFC the
|
||||
-- invitation grants, the opaque token the email link encodes,
|
||||
-- the lifecycle state, and the audit trail (who invited, when
|
||||
-- accepted, by which user_id if any).
|
||||
--
|
||||
-- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc)
|
||||
-- after an invitation is accepted. This is the table the
|
||||
-- write-gate consults: "is the viewer named here for this RFC?"
|
||||
-- Separating the two means the invitation row carries the
|
||||
-- issue/accept lifecycle while the collaborator row is the
|
||||
-- compact membership-check substrate. A grant via collaborator
|
||||
-- can exist independently of a live invitation (admin-only
|
||||
-- direct insert is a §19.2 candidate; v0.16.0 only writes
|
||||
-- collaborator rows via the accept path).
|
||||
--
|
||||
-- Authorization model the application layer enforces on top of these
|
||||
-- rows (not encoded in SQL — the schema is just storage):
|
||||
--
|
||||
-- * Writes (open PR, post discussion message, open discussion
|
||||
-- thread) to an RFC require ONE of:
|
||||
-- (a) the viewer is named in this RFC's `rfc_collaborators`
|
||||
-- with the appropriate role_in_rfc, OR
|
||||
-- (b) the viewer holds a globally privileged role (admin,
|
||||
-- owner of the platform) per the existing §6 helpers, OR
|
||||
-- (c) the viewer is named in the RFC's frontmatter owners
|
||||
-- list (the §6 RFC-owner concept, which already grants
|
||||
-- the maximal per-RFC capability).
|
||||
--
|
||||
-- * Reads remain on the v0.6.0 anonymous-read contract — anyone
|
||||
-- can read any non-withdrawn RFC. Item #12 does not narrow this.
|
||||
--
|
||||
-- * Only the RFC's owner (per `cached_rfcs.owners_json`) can
|
||||
-- invite. App admins/owners also can (they have the maximal
|
||||
-- per-RFC capability by construction).
|
||||
--
|
||||
-- Storage shape — `rfc_invitations`:
|
||||
--
|
||||
-- * `id` — surrogate key; the revoke-by-id surface addresses a
|
||||
-- single row without leaking the token shape.
|
||||
--
|
||||
-- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to.
|
||||
-- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC
|
||||
-- cascades its invitations away cleanly. The §4 cache contract
|
||||
-- says cached_rfcs is rebuildable from Gitea; per the same
|
||||
-- contract, invitations are app-truth (no Git substrate), so
|
||||
-- the cascade is the right direction.
|
||||
--
|
||||
-- * `inviter_user_id` — the owner who issued the invite. ON
|
||||
-- DELETE SET NULL because losing the inviter's user row should
|
||||
-- not cascade-delete invitations they sent (the row stays as
|
||||
-- audit; the UI renders "by (deleted user)" the same way the
|
||||
-- audit log does for orphaned actors).
|
||||
--
|
||||
-- * `invitee_email` — TEXT NOT NULL; the email the invitation
|
||||
-- was sent to. Stored verbatim (case-preserved) so the email
|
||||
-- body can address the invitee in their original shape; the
|
||||
-- accept path matches case-insensitively.
|
||||
--
|
||||
-- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}.
|
||||
-- `contributor` lets the user open PRs against the RFC AND
|
||||
-- post in its discussion (PR-permission strictly includes
|
||||
-- discussion-permission); `discussant` only lets them post
|
||||
-- in discussion. Future roles (e.g., 'arbiter') would be
|
||||
-- additions; v0.16.0 ships the two.
|
||||
--
|
||||
-- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' |
|
||||
-- 'expired'}. Default 'pending'. `accepted` flips on the
|
||||
-- accept endpoint; `revoked` on the owner's revoke gesture;
|
||||
-- `expired` lazily on read (the accept endpoint refuses a
|
||||
-- row whose expires_at has passed, regardless of the column
|
||||
-- value).
|
||||
--
|
||||
-- * `token` — opaque high-entropy string the email link
|
||||
-- encodes. Stored verbatim (not hashed) because the
|
||||
-- invitation token is single-use and lower-stakes than a
|
||||
-- session token: it grants per-RFC role only, and is bounded
|
||||
-- by expires_at. Hashing the token here is a §19.2 candidate
|
||||
-- if/when the threat model demands it. UNIQUE so the accept
|
||||
-- path is a single-row lookup.
|
||||
--
|
||||
-- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days`
|
||||
-- at insert time by the application layer. Accept refuses past
|
||||
-- this point; the row can still be revoked or re-issued.
|
||||
--
|
||||
-- * `created_at` — when the invitation was issued.
|
||||
--
|
||||
-- * `accepted_at` — when the invitee accepted (NULL until then).
|
||||
--
|
||||
-- * `accepted_by_user_id` — the user row that accepted. NULL
|
||||
-- until acceptance. On a fresh email (no platform user yet)
|
||||
-- the accept endpoint requires the invitee to sign in first
|
||||
-- via the v0.7.0 OTC path; that path provisions the user row,
|
||||
-- after which the accept call lands the user_id here.
|
||||
--
|
||||
-- Indexing:
|
||||
--
|
||||
-- * UNIQUE on `token` so the accept lookup is a primary-key-shape
|
||||
-- hit and accidental collisions are detectable at insert time.
|
||||
-- * (rfc_slug, status) for the owner's "list pending/accepted for
|
||||
-- this RFC" surface — the most frequent query.
|
||||
-- * (invitee_email, status) for a future cross-RFC "show me my
|
||||
-- pending invites" inbox; v0.16.0 doesn't ship that surface but
|
||||
-- the index slot is cheap and aligned with the data shape.
|
||||
--
|
||||
-- Storage shape — `rfc_collaborators`:
|
||||
--
|
||||
-- * `id` — surrogate key.
|
||||
-- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE.
|
||||
-- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE.
|
||||
-- A deleted user loses every per-RFC role automatically (mirrors
|
||||
-- the device_trust / passcode cascade shape).
|
||||
-- * `role_in_rfc` — same CHECK as the invitation table.
|
||||
-- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE
|
||||
-- SET NULL. Audit pointer to the row that minted this
|
||||
-- collaborator; NULL is allowed so a future admin-direct grant
|
||||
-- path (a §19.2 candidate) can mint a collaborator with no
|
||||
-- originating invitation. v0.16.0 always populates this.
|
||||
-- * `created_at` — when the collaborator row was minted.
|
||||
--
|
||||
-- Indexing on collaborators:
|
||||
-- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most
|
||||
-- one role per RFC. Re-accepting an invitation upgrades the row
|
||||
-- (discussant → contributor) but never duplicates.
|
||||
-- * (user_id) for "what RFCs am I a collaborator on?" reads.
|
||||
|
||||
CREATE TABLE rfc_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitee_email TEXT NOT NULL,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||
token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
accepted_at TEXT,
|
||||
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||
|
||||
CREATE TABLE rfc_collaborators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
@@ -1,105 +0,0 @@
|
||||
-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||
--
|
||||
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
|
||||
-- here an *admin* creates a `users` row *before* the invited person has
|
||||
-- ever signed in, assigns them a role at creation time, and sends them
|
||||
-- an invite email carrying a claim link. The invitee clicks the link,
|
||||
-- the claim flow consumes the token (which is itself proof of email
|
||||
-- control), the row is marked claimed, and the user is signed in
|
||||
-- inheriting the pre-set role.
|
||||
--
|
||||
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
|
||||
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
|
||||
-- in the same wave; the two features live in distinct tables
|
||||
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
|
||||
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
|
||||
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
|
||||
--
|
||||
-- Open-question decisions settled in this release (see CHANGELOG):
|
||||
-- * No `users` table changes — the brief floated `first_sign_in_at`
|
||||
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
|
||||
-- but the existing `users.last_seen_at` is NOT NULL with a
|
||||
-- `datetime('now')` default (migrations/001) and there is no
|
||||
-- `first_sign_in_at` column. Rather than land a schema migration to
|
||||
-- introduce one, the discriminator is the existence of an active
|
||||
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
|
||||
-- `invited_user_id`. The admin user-listing carries a
|
||||
-- `pending_invite` field populated via that join; on claim, the
|
||||
-- invite row's `claimed_at` populates and the badge clears.
|
||||
-- No new `permission_state` value is introduced either.
|
||||
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
|
||||
-- rest), not a JWT, so admin revocation by row UPDATE works
|
||||
-- without distributing a key-rotation gesture.
|
||||
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
|
||||
-- `backend/app/invites.py`); env-var configurability is a follow-up.
|
||||
-- * Immediate-send (no admin-review-then-send queue) ships in this
|
||||
-- release; admin-preview is a future enhancement.
|
||||
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
|
||||
-- v0.17.0 is one-at-a-time.
|
||||
--
|
||||
-- Storage shape:
|
||||
--
|
||||
-- * `id` — surrogate key. Lets the admin "pending invites" listing
|
||||
-- address a row without leaking the token shape.
|
||||
-- * `email` — the address the invite was sent to (case-insensitive
|
||||
-- match at claim time, persisted verbatim for the audit trail).
|
||||
-- * `role` — the role the invitee inherits on first sign-in. Pinned
|
||||
-- via CHECK to the same set the §6.1 role flip accepts
|
||||
-- (`owner` / `admin` / `contributor`) so a future role-set drift
|
||||
-- fails loudly at insert rather than provisioning a ghost role.
|
||||
-- * `first_name` / `last_name` — captured at create time so the
|
||||
-- invitee skips the v0.8.0 capture-form step on first sign-in.
|
||||
-- * `custom_message` — optional free-text from the admin (max 500
|
||||
-- chars enforced at the API layer); embedded verbatim in the
|
||||
-- email body if present.
|
||||
-- * `token_hash` — bcrypt hash of the random opaque token. The
|
||||
-- raw token only ever lives in the outbound email link and the
|
||||
-- inbound claim body; server-side storage is the hash.
|
||||
-- * `expires_at` — `created_at + 7 days` (default at the app layer
|
||||
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
|
||||
-- the claim path refuses with HTTP 410.
|
||||
-- * `created_at` — when the admin issued the invite.
|
||||
-- * `created_by_admin_id` — FK into users(id) for the admin who
|
||||
-- created the invite (no cascade; if the admin's row is deleted
|
||||
-- the invite history stays so the audit trail survives).
|
||||
-- * `claimed_at` — non-NULL once the invitee successfully claims.
|
||||
-- A second claim attempt against an already-claimed row returns
|
||||
-- HTTP 410.
|
||||
-- * `claimed_by_user_id` — FK into users(id) for the user row
|
||||
-- that consumed the token. In the common case this equals the
|
||||
-- freshly-provisioned row that was created at invite time; the
|
||||
-- FK lets the admin's "claimed" list join through.
|
||||
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
|
||||
-- row. Created at invite time with `last_seen_at IS NULL` so the
|
||||
-- v0.9.0 admin user-management page can render a "(pending
|
||||
-- invite)" badge alongside existing users.
|
||||
--
|
||||
-- Indexing:
|
||||
-- * Unique index on `token_hash` documents the no-collision
|
||||
-- invariant (256 bits of CSPRNG entropy; collision is
|
||||
-- structurally impossible, the unique constraint catches a
|
||||
-- bug at insert time).
|
||||
-- * Index on `(email, claimed_at)` so the "is this email already
|
||||
-- invited?" pre-check the admin endpoint runs is a covering walk.
|
||||
-- * Index on `(created_by_admin_id, created_at DESC)` for the
|
||||
-- admin's "invites I've sent" listing.
|
||||
|
||||
CREATE TABLE user_invite_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
custom_message TEXT NOT NULL DEFAULT '',
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
|
||||
claimed_at TEXT,
|
||||
claimed_by_user_id INTEGER REFERENCES users(id),
|
||||
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
|
||||
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
|
||||
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
|
||||
@@ -1,649 +0,0 @@
|
||||
"""End-to-end integration tests for v0.17.0's admin-create user +
|
||||
invite-email + claim-flow vertical (roadmap item #16, §6.1).
|
||||
|
||||
The release lands three halves of the same surface:
|
||||
|
||||
* **Admin-create user** at `POST /api/admin/users`. The admin types
|
||||
email, first/last name, role, and an optional custom message. The
|
||||
framework provisions the invitee `users` row (granted, with the
|
||||
chosen role) and writes a `user_invite_tokens` row carrying the
|
||||
bcrypt-hashed opaque token. The "pending invite" discriminator is
|
||||
the active `user_invite_tokens` row joined on `invited_user_id`,
|
||||
not a NULL column on `users` (the existing `last_seen_at` column
|
||||
is NOT NULL). An invite email dispatches via the existing SMTP
|
||||
relay.
|
||||
|
||||
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
|
||||
Lists active (not claimed, not expired) invites for the admin's
|
||||
"I sent these but they haven't been claimed yet" view.
|
||||
|
||||
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
|
||||
they got via email; the framework verifies, marks the row claimed,
|
||||
signs them in (skipping OTC on first sign-in per the roadmap), and
|
||||
returns a `needs_passcode` hint for the frontend to route to the
|
||||
passcode-set screen.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* The happy path: admin creates → invite row + email envelope land →
|
||||
invitee claims with the token → session is established.
|
||||
* Non-admin caller is refused 403.
|
||||
* Self-invite is refused 422.
|
||||
* Duplicate email is refused 409.
|
||||
* Owner-grant by non-owner is refused 422.
|
||||
* Malformed role is refused 422 (pydantic regex).
|
||||
* Custom message over 500 chars is refused 422 (pydantic max_length).
|
||||
* Claim with valid token: signs in + marks row claimed.
|
||||
* Claim with expired token: HTTP 410.
|
||||
* Claim with already-claimed token: HTTP 410.
|
||||
* Claim with unknown token: HTTP 400.
|
||||
* The admin-create gesture writes a `permission_events` row with
|
||||
event_kind='user_invited'.
|
||||
* The user listing surfaces the `pending_invite` field for invited-
|
||||
but-not-yet-claimed users, and clears it after claim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
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_invite_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
"""Pull the invite-kind envelopes off the shared notifier buffer.
|
||||
|
||||
Mirrors the OTC code-extraction helper in
|
||||
test_admin_users_vertical.py — invite emails land in the same
|
||||
`_SENT` buffer with `kind='invite'`.
|
||||
"""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "invite":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
out.append(env)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_claim_url(envelope: dict) -> str:
|
||||
"""Pull the claim URL out of the invite email body."""
|
||||
for line in envelope["body"].splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("http") and "/invites/claim" in line:
|
||||
return line
|
||||
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
|
||||
|
||||
|
||||
def _extract_claim_token(envelope: dict) -> str:
|
||||
"""Pull the `token` query-string param out of the claim URL."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
url = _extract_claim_url(envelope)
|
||||
qs = parse_qs(urlparse(url).query)
|
||||
return qs["token"][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin create + invite — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
|
||||
"""Admin creates → user row + invite-token row + email envelope all
|
||||
land; the response carries the created ids and the inviter is the
|
||||
admin who issued the gesture."""
|
||||
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=100, login="adminzero", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=100, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin",
|
||||
email="adminzero@test",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "invitee@example.com",
|
||||
"first_name": "Inv",
|
||||
"last_name": "Tee",
|
||||
"role": "contributor",
|
||||
"custom_message": "We chatted at the conference — welcome!",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["email"] == "invitee@example.com"
|
||||
assert body["role"] == "contributor"
|
||||
assert body["invite_id"] > 0
|
||||
assert body["invited_user_id"] > 0
|
||||
|
||||
# User row exists with the chosen role + granted. The "pending
|
||||
# invite" discriminator is the active `user_invite_tokens` row,
|
||||
# not a NULL column on `users` — see the invites.create_invite
|
||||
# docstring for the reasoning.
|
||||
row = db.conn().execute(
|
||||
"SELECT role, permission_state, first_name, last_name "
|
||||
"FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("invitee@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["role"] == "contributor"
|
||||
assert row["permission_state"] == "granted"
|
||||
assert row["first_name"] == "Inv"
|
||||
assert row["last_name"] == "Tee"
|
||||
|
||||
# Invite-token row exists with the matching ids and the custom
|
||||
# message persisted verbatim.
|
||||
invite = db.conn().execute(
|
||||
"SELECT email, role, custom_message, created_by_admin_id, "
|
||||
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
|
||||
(body["invite_id"],),
|
||||
).fetchone()
|
||||
assert invite is not None
|
||||
assert invite["email"] == "invitee@example.com"
|
||||
assert invite["role"] == "contributor"
|
||||
assert invite["custom_message"] == "We chatted at the conference — welcome!"
|
||||
assert invite["created_by_admin_id"] == 100
|
||||
assert invite["invited_user_id"] == body["invited_user_id"]
|
||||
assert invite["claimed_at"] is None
|
||||
|
||||
# Email envelope landed with the invite kind and embeds the
|
||||
# custom message + claim URL. The inviter display name comes
|
||||
# off the DB row (which provision_user_row sets to
|
||||
# login.capitalize()), not the sign_in_as cookie payload.
|
||||
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
|
||||
assert len(envelopes) == 1
|
||||
env = envelopes[0]
|
||||
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
|
||||
assert "We chatted at the conference — welcome!" in env["body"]
|
||||
# Claim URL is well-formed.
|
||||
url = _extract_claim_url(env)
|
||||
assert "/invites/claim?token=" in url
|
||||
|
||||
# `permission_events` row landed with event_kind='user_invited'.
|
||||
ev = db.conn().execute(
|
||||
"SELECT actor_user_id, subject_user_id, event_kind, details "
|
||||
"FROM permission_events WHERE event_kind = 'user_invited'"
|
||||
).fetchall()
|
||||
assert len(ev) == 1
|
||||
assert ev[0]["actor_user_id"] == 100
|
||||
assert ev[0]["subject_user_id"] == body["invited_user_id"]
|
||||
details = json.loads(ev[0]["details"])
|
||||
assert details["email"] == "invitee@example.com"
|
||||
assert details["role"] == "contributor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refusals on the admin-create endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
|
||||
"""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=110, login="contrib", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=110, gitea_login="contrib",
|
||||
display_name="Contrib", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "x@y.com", "role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "x@y.com", "role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
|
||||
"""An admin trying to invite their own email is refused 422 —
|
||||
self-invite is the wrong channel; the role-change endpoint exists
|
||||
for self-edits."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=120, login="adm", role="admin")
|
||||
# Manually set the admin's email since provision_user_row's
|
||||
# fixture uses login@test; this is what we'll try to self-invite.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email = ? WHERE id = ?",
|
||||
("selfinviter@example.com", 120),
|
||||
)
|
||||
sign_in_as(
|
||||
client, user_id=120, gitea_login="adm",
|
||||
display_name="Adm", role="admin",
|
||||
email="selfinviter@example.com",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "selfinviter@example.com",
|
||||
"role": "contributor",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "yourself" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
|
||||
"""An admin trying to invite an email that already maps to a users
|
||||
row is refused 409 — the existing role / grant gestures are the
|
||||
right surface for an existing user."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=130, login="adminD", role="admin")
|
||||
provision_user_row(user_id=131, login="existingone", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=130, gitea_login="adminD",
|
||||
display_name="Admin D", role="admin",
|
||||
)
|
||||
# provision_user_row sets email to <login>@test, so:
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "existingone@test",
|
||||
"role": "contributor",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
|
||||
"""An admin (not owner) trying to invite a fresh user as `owner` is
|
||||
refused 422 — §6.1's owner-zero is the only bootstrap path."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=140, gitea_login="adminNoOwner",
|
||||
display_name="Admin", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "wouldbeowner@example.com",
|
||||
"role": "owner",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
|
||||
"""A sitting owner can invite a fresh user as `owner` — the §6.1
|
||||
role-grant channel. Sanity check that the owner-grant path itself
|
||||
works, paired with the refusal above."""
|
||||
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=150, login="ownerzero", role="owner")
|
||||
sign_in_as(
|
||||
client, user_id=150, gitea_login="ownerzero",
|
||||
display_name="Owner Zero", role="owner",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "newowner@example.com",
|
||||
"role": "owner",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newowner@example.com",),
|
||||
).fetchone()
|
||||
assert row["role"] == "owner"
|
||||
|
||||
|
||||
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
|
||||
"""The pydantic regex refuses any role outside the §6.1 set."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=160, login="adminR", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=160, gitea_login="adminR",
|
||||
display_name="Admin R", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "ok@example.com",
|
||||
"role": "superuser",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
|
||||
"""Custom message over the 500-char ceiling is refused 422."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=170, login="adminM", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=170, gitea_login="adminM",
|
||||
display_name="Admin M", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "ok@example.com",
|
||||
"role": "contributor",
|
||||
"custom_message": "x" * 501,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claim flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
|
||||
"""End-to-end: admin creates → invitee posts the token to
|
||||
/api/invites/claim → session lands + row marked claimed +
|
||||
last_seen_at stamps on the user row (the pending-invite
|
||||
discriminator clears)."""
|
||||
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=200, login="adminC", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=200, gitea_login="adminC",
|
||||
display_name="Admin C", role="admin",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "claimant@example.com",
|
||||
"first_name": "Clai",
|
||||
"last_name": "Mant",
|
||||
"role": "contributor",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
invite_id = r.json()["invite_id"]
|
||||
invited_user_id = r.json()["invited_user_id"]
|
||||
|
||||
env = _outbound_invite_envelopes("claimant@example.com")[0]
|
||||
token = _extract_claim_token(env)
|
||||
|
||||
# The invitee's request is anonymous (they have no session
|
||||
# yet). We clear the admin's session cookie to simulate this.
|
||||
client.cookies.clear()
|
||||
|
||||
r = client.post(
|
||||
"/api/invites/claim",
|
||||
json={"token": token},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["user"]["id"] == invited_user_id
|
||||
assert body["user"]["role"] == "contributor"
|
||||
assert body["user"]["permission_state"] == "granted"
|
||||
# The user has no passcode set yet → frontend should route to
|
||||
# passcode-set per the roadmap.
|
||||
assert body["needs_passcode"] is True
|
||||
|
||||
# Row marked claimed; last_seen_at populated.
|
||||
invite = db.conn().execute(
|
||||
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
|
||||
"WHERE id = ?",
|
||||
(invite_id,),
|
||||
).fetchone()
|
||||
assert invite["claimed_at"] is not None
|
||||
assert invite["claimed_by_user_id"] == invited_user_id
|
||||
|
||||
user_row = db.conn().execute(
|
||||
"SELECT last_seen_at FROM users WHERE id = ?",
|
||||
(invited_user_id,),
|
||||
).fetchone()
|
||||
assert user_row["last_seen_at"] is not None
|
||||
|
||||
|
||||
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
|
||||
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, invites
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=210, login="adminE", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=210, gitea_login="adminE",
|
||||
display_name="Admin E", role="admin",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
# Create the invite, then back-date the expires_at to the past.
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "expired@example.com",
|
||||
"role": "contributor",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
invite_id = r.json()["invite_id"]
|
||||
db.conn().execute(
|
||||
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||
"WHERE id = ?",
|
||||
(invite_id,),
|
||||
)
|
||||
env = _outbound_invite_envelopes("expired@example.com")[0]
|
||||
token = _extract_claim_token(env)
|
||||
|
||||
client.cookies.clear()
|
||||
r = client.post("/api/invites/claim", json={"token": token})
|
||||
assert r.status_code == 410, r.text
|
||||
assert "expired" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
|
||||
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=220, login="adminA", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=220, gitea_login="adminA",
|
||||
display_name="Admin A", role="admin",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "twice@example.com",
|
||||
"role": "contributor",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
env = _outbound_invite_envelopes("twice@example.com")[0]
|
||||
token = _extract_claim_token(env)
|
||||
|
||||
client.cookies.clear()
|
||||
# First claim succeeds.
|
||||
r = client.post("/api/invites/claim", json={"token": token})
|
||||
assert r.status_code == 200
|
||||
# Second claim, with the same token, refuses with 410.
|
||||
client.cookies.clear()
|
||||
r = client.post("/api/invites/claim", json={"token": token})
|
||||
assert r.status_code == 410, r.text
|
||||
assert "already" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
|
||||
"""A token that doesn't match any active invite is HTTP 400."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# No invite ever created; the token is whatever the attacker
|
||||
# types in. The endpoint should refuse without disclosing
|
||||
# whether the token "looked" right.
|
||||
r = client.post(
|
||||
"/api/invites/claim",
|
||||
json={"token": "totally-made-up-token-string-that-is-not-real"},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending-invite admin listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
|
||||
"""The `GET /api/admin/users/invites` listing filters to active
|
||||
invites — claimed and expired rows do not surface here (the admin
|
||||
user-listing carries the per-row pending-invite badge for the
|
||||
living rows; once claimed, the badge clears)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=300, login="adminL", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=300, gitea_login="adminL",
|
||||
display_name="Admin L", role="admin",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
# Create three invites: one stays pending, one we'll claim, one
|
||||
# we'll back-date to expired.
|
||||
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": email, "role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Claim the middle one.
|
||||
env = _outbound_invite_envelopes("claimed@ex.co")[0]
|
||||
token_claim = _extract_claim_token(env)
|
||||
|
||||
# Expire the third one.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||
"WHERE email = 'expired@ex.co'"
|
||||
)
|
||||
|
||||
# The admin's session is still on the cookie. Claim works
|
||||
# anonymously; we clear and restore.
|
||||
admin_cookie = client.cookies.get("rfc_session")
|
||||
client.cookies.clear()
|
||||
r = client.post("/api/invites/claim", json={"token": token_claim})
|
||||
assert r.status_code == 200
|
||||
client.cookies.set("rfc_session", admin_cookie)
|
||||
|
||||
r = client.get("/api/admin/users/invites")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
emails = sorted(i["email"] for i in items)
|
||||
assert emails == ["alive@ex.co"]
|
||||
|
||||
|
||||
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
|
||||
"""The `/api/admin/users` listing surfaces `pending_invite` while
|
||||
the invite is unclaimed; after the invitee claims, the row's
|
||||
pending_invite is null."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=310, login="adminB", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=310, gitea_login="adminB",
|
||||
display_name="Admin B", role="admin",
|
||||
)
|
||||
_reset_outbound()
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "badgey@ex.co", "role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
invited_id = r.json()["invited_user_id"]
|
||||
|
||||
# Before claim — pending_invite is populated.
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||
assert row["pending_invite"] is not None
|
||||
assert row["pending_invite"]["invite_id"] > 0
|
||||
|
||||
# Claim.
|
||||
env = _outbound_invite_envelopes("badgey@ex.co")[0]
|
||||
token = _extract_claim_token(env)
|
||||
admin_cookie = client.cookies.get("rfc_session")
|
||||
client.cookies.clear()
|
||||
r = client.post("/api/invites/claim", json={"token": token})
|
||||
assert r.status_code == 200
|
||||
client.cookies.set("rfc_session", admin_cookie)
|
||||
|
||||
# After claim — pending_invite is null.
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||
assert row["pending_invite"] is None
|
||||
|
||||
|
||||
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
|
||||
"""The listing requires admin/owner; contributor gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=320, login="contribL", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=320, gitea_login="contribL",
|
||||
display_name="Contrib L", role="contributor",
|
||||
)
|
||||
r = client.get("/api/admin/users/invites")
|
||||
assert r.status_code == 403
|
||||
@@ -22,7 +22,6 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -132,11 +131,6 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||
|
||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
|
||||
# per-RFC contributor invitation to cut a branch. In the
|
||||
# production flow, ben would invite her via /invitations and
|
||||
# she'd accept; we shortcut to the same end-state.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
|
||||
@@ -34,7 +34,6 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -249,9 +248,6 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
|
||||
# contributor invitation to cut an edit branch on the super-draft.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
@@ -501,8 +497,6 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): alice needs per-RFC contributor access.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
|
||||
@@ -21,7 +21,6 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -141,9 +140,6 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
|
||||
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
|
||||
# invitation to cut branches and open PRs on alice's RFC.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
@@ -296,9 +292,6 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Bob is neither owner nor arbiter — the non-merge baseline.
|
||||
# v0.16.0 (item #12): bob still needs an accepted contributor
|
||||
# invitation to cut the branch + open the PR.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
@@ -371,10 +364,6 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
|
||||
# accepted per-RFC invitation to cut a branch on alice's RFC.
|
||||
# Alice is the seeded RFC owner so she doesn't need one.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts a branch and accepts a change on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
@@ -395,28 +395,6 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
|
||||
"""v0.16.0 / item #12 test seam: directly insert an accepted-
|
||||
invitation collaborator row so a non-owner contributor can pass
|
||||
the per-RFC write gate without going through the email round-trip.
|
||||
|
||||
Equivalent in effect to the invitation→accept dance the production
|
||||
code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
|
||||
"alice owns OHM, bob contributes" shape without rewriting the
|
||||
setup. The invitation_id is left NULL — collaborators minted via
|
||||
a direct admin gesture (a §19.2 candidate) carry the same shape.
|
||||
"""
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO rfc_collaborators
|
||||
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||
VALUES (?, ?, ?, NULL)
|
||||
""",
|
||||
(rfc_slug, user_id, role_in_rfc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
"""End-to-end integration tests for v0.16.0's owner-only invite for
|
||||
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
|
||||
|
||||
The release lands a per-RFC membership layer:
|
||||
|
||||
* `rfc_invitations` — issued by the RFC's owner, addressed to an
|
||||
email, granting one of two roles ('contributor' or 'discussant').
|
||||
* `rfc_collaborators` — the accepted-invitation substrate; the
|
||||
table the per-RFC write gate consults.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* Only the RFC's owner (or a platform admin/owner) can invite —
|
||||
a platform-granted but non-owner user gets 403.
|
||||
* Creating an invitation lands a row, mints a token, and queues
|
||||
an envelope on the SMTP buffer.
|
||||
* Re-inviting the same (email, role) on the same RFC returns 409.
|
||||
* The accept endpoint requires the accepting user's email to match
|
||||
the invitee_email (case-insensitive).
|
||||
* Acceptance lands a rfc_collaborators row and flips the
|
||||
invitation to 'accepted'.
|
||||
* Re-accepting the same invitation is idempotent (200, changed=false).
|
||||
* An expired invitation refuses 409 even if the row's column status
|
||||
is still 'pending'.
|
||||
* A revoked invitation refuses 409.
|
||||
* The owner's listing carries pending + accepted in one response.
|
||||
* The per-RFC discussion-write gate refuses a non-invited
|
||||
platform-granted user 403 (was previously 200 before v0.16.0).
|
||||
* The same gate admits a user who holds an accepted 'discussant'
|
||||
invitation.
|
||||
* The same gate admits a user who holds an accepted 'contributor'
|
||||
invitation (contributor strictly includes discussion).
|
||||
* The platform admin/owner is admitted regardless of per-RFC
|
||||
membership (the platform-level capability path).
|
||||
* The /api/admin/users listing carries `rfc_invitations` per-user
|
||||
after an acceptance — the §17 admin surface hook.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
|
||||
Same access pattern as the OTC tests use for `kind='otc'`."""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "rfc_invitation":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
out.append(env)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create / list / revoke (owner-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
|
||||
"""The end-to-end create gesture: RFC owner posts an invitation,
|
||||
a row lands, the token comes back in the response, and an
|
||||
`rfc_invitation`-kind envelope hits the SMTP buffer."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# The frontmatter owner of the seeded RFC is "alice" (per
|
||||
# seed_active_rfc's default), so we sign in as that user.
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["rfc_slug"] == "ohm"
|
||||
assert body["invitee_email"] == "newperson@example.com"
|
||||
assert body["role_in_rfc"] == "contributor"
|
||||
assert body["status"] == "pending"
|
||||
assert body["token"] and len(body["token"]) > 16
|
||||
|
||||
# Row landed.
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
|
||||
).fetchone()
|
||||
assert row["rfc_slug"] == "ohm"
|
||||
assert row["invitee_email"] == "newperson@example.com"
|
||||
assert row["inviter_user_id"] == 1
|
||||
assert row["status"] == "pending"
|
||||
|
||||
# Email envelope went out.
|
||||
envs = _invitation_envelopes("newperson@example.com")
|
||||
assert len(envs) == 1
|
||||
assert "OHM" in envs[0]["subject"]
|
||||
assert body["token"] in envs[0]["body"]
|
||||
|
||||
|
||||
def test_non_owner_cannot_invite(app_with_fake_gitea):
|
||||
"""A platform-granted user who isn't in the RFC's frontmatter
|
||||
owners list cannot invite — 403. Distinct from the
|
||||
require_contributor gate (which would be 401 for anonymous)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# alice is the RFC owner per the seed; bob is a regular
|
||||
# platform-granted contributor with no per-RFC role.
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="bob",
|
||||
display_name="Bob", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
|
||||
"""Per §6.1 the platform admin/owner role carries the maximal
|
||||
per-RFC capability, so admins can invite on any RFC even if
|
||||
they're not in its owners list."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_anonymous_cannot_invite(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
|
||||
"""Refuse a duplicate pending invitation for the same (email, role)
|
||||
on the same RFC. A different role on the same email is allowed
|
||||
(the owner may want to upgrade discussant → contributor)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r1 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
r2 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r2.status_code == 409
|
||||
|
||||
# Same email, different role is allowed.
|
||||
r3 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r3.status_code == 200
|
||||
|
||||
|
||||
def test_owner_can_list_invitations(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
client.post("/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
|
||||
client.post("/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
|
||||
|
||||
r = client.get("/api/rfcs/ohm/invitations")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
emails = sorted(i["invitee_email"] for i in items)
|
||||
assert emails == ["a@example.com", "b@example.com"]
|
||||
assert all(i["status"] == "pending" for i in items)
|
||||
# The inviter is named.
|
||||
assert all(i["inviter_login"] == "alice" for i in items)
|
||||
|
||||
|
||||
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
|
||||
"""Revoke flips a pending invitation to 'revoked'. An already-
|
||||
accepted invitation refuses 409 — accepted membership is removed
|
||||
via a different (future) surface; the v0.16.0 revoke only lifts
|
||||
the pending link."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
invitation_id = r.json()["id"]
|
||||
|
||||
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "revoked"
|
||||
|
||||
# Re-revoke refuses 409.
|
||||
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
assert r2.status_code == 409
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
|
||||
).fetchone()
|
||||
assert row["status"] == "revoked"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept (invitee-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
|
||||
"""The end-to-end accept gesture: the invitee signs in, posts the
|
||||
token, and an rfc_collaborators row lands at the issued role."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
# provision_user_row sets the email to "<login>@test", so the
|
||||
# invitee row we'll create needs the same email shape.
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice (owner) invites newbie@test.
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["token"]
|
||||
|
||||
# Switch to newbie, accept.
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test",
|
||||
)
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["changed"] is True
|
||||
assert body["rfc_slug"] == "ohm"
|
||||
assert body["role_in_rfc"] == "contributor"
|
||||
|
||||
# Collaborator row landed; invitation flipped.
|
||||
collab = db.conn().execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
|
||||
).fetchone()
|
||||
assert collab is not None
|
||||
assert collab["role_in_rfc"] == "contributor"
|
||||
|
||||
inv = db.conn().execute(
|
||||
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
|
||||
(token,),
|
||||
).fetchone()
|
||||
assert inv["status"] == "accepted"
|
||||
assert inv["accepted_by_user_id"] == 2
|
||||
|
||||
|
||||
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
|
||||
"""The accepting user's email must match the invitation's
|
||||
invitee_email (case-insensitive)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="mallory", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
# mallory's email is "mallory@test", not "intended@example.com".
|
||||
sign_in_as(client, user_id=2, gitea_login="mallory",
|
||||
display_name="Mallory", role="contributor",
|
||||
email="mallory@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
invitation_id = r.json()["id"]
|
||||
token = r.json()["token"]
|
||||
|
||||
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
|
||||
"""An invitation past its `expires_at` is refused 409 even if
|
||||
the row's column status is still 'pending'. We backdate the
|
||||
expires_at directly to model the elapsed-window state."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
invitation_id = r.json()["id"]
|
||||
|
||||
# Backdate.
|
||||
db.conn().execute(
|
||||
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
|
||||
(invitation_id,),
|
||||
)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
|
||||
"""Re-accepting the same already-accepted invitation reads as a
|
||||
200 no-op with `changed=false`. The collaborator row is unchanged."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r1 = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["changed"] is True
|
||||
|
||||
r2 = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["changed"] is False
|
||||
|
||||
# Still exactly one collaborator row.
|
||||
rows = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
|
||||
).fetchone()
|
||||
assert rows["n"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discussion-write gate enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
|
||||
"""v0.16.0 narrows the discussion-write gate: a platform-granted
|
||||
user with no per-RFC role gets 403 when posting to the
|
||||
discussion. (v0.6.0 left the gate at require_contributor only;
|
||||
item #12 layers can_discuss_rfc on top.)"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# bob is platform-granted but not in OHM's owners list and has
|
||||
# no invitation. The thread-create surface refuses 403.
|
||||
sign_in_as(client, user_id=2, gitea_login="bob",
|
||||
display_name="Bob", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question", "message": "Should I be allowed?"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice invites newbie as a discussant.
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
# newbie accepts.
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
# newbie can now post to the discussion.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question", "message": "Now I can speak."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_contributor_role_includes_discussion(app_with_fake_gitea):
|
||||
"""A 'contributor' per-RFC role strictly includes discussion
|
||||
permission — accepting a contributor invitation admits the user
|
||||
to the discussion endpoint too."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Q", "message": "Hello."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
|
||||
"""Per §6.1 / item #12's permission shape: platform admins/owners
|
||||
can write to any RFC's discussion regardless of per-RFC
|
||||
membership."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Admin chime", "message": "Drive-by from admin."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
|
||||
"""The frontmatter RFC owner is admitted by virtue of being on
|
||||
the owners list — they don't need to invite themselves."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Owner thought", "message": "Kicking off the conversation."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-page hook (additive on /api/admin/users)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
|
||||
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
|
||||
each user row carries an `rfc_invitations` array listing the
|
||||
per-RFC roles they hold. Empty array for users without any."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice invites newbie; newbie accepts.
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
# Admin lists.
|
||||
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin")
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
|
||||
assert isinstance(newbie_row["rfc_invitations"], list)
|
||||
assert len(newbie_row["rfc_invitations"]) == 1
|
||||
invite = newbie_row["rfc_invitations"][0]
|
||||
assert invite["rfc_slug"] == "ohm"
|
||||
assert invite["role_in_rfc"] == "contributor"
|
||||
assert invite["inviter_login"] == "alice"
|
||||
|
||||
# Users with no invitations carry an empty array, not null.
|
||||
alice_row = next(i for i in items if i["gitea_login"] == "alice")
|
||||
assert alice_row["rfc_invitations"] == []
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.17.0",
|
||||
"version": "0.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+6
-38
@@ -15,8 +15,6 @@ import Philosophy from './components/Philosophy.jsx'
|
||||
import Docs from './components/Docs.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||
import InviteClaim from './components/InviteClaim.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||||
import Privacy from './pages/Privacy.jsx'
|
||||
@@ -50,43 +48,23 @@ export default function App() {
|
||||
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
// v0.15.0 + #21 Part C — bind the authenticated user id AND
|
||||
// durable user properties to the analytics session when sign-in
|
||||
// lands; reset on sign-out (viewer flips to null). The wrapper
|
||||
// queues these calls until consent + init resolve, so the order
|
||||
// is safe even on a cold load.
|
||||
//
|
||||
// Property bag passed to identify (set vs setOnce per #21 Part C):
|
||||
// set: role, permission_state, passcode_set, device_trusted
|
||||
// (these can change mid-account-life — refresh each sign-in)
|
||||
// setOnce: first_sign_in_at, account_created_at
|
||||
// (immutable user-history markers — set on the first
|
||||
// sign-in that observes them, never overwritten)
|
||||
//
|
||||
// PII discipline: NO email, NO display_name, NO gitea_login passed
|
||||
// through — Amplitude only sees opaque ids + enums + timestamps +
|
||||
// booleans.
|
||||
// v0.15.0 — bind the authenticated user id to the analytics
|
||||
// session when sign-in lands; reset on sign-out (viewer flips to
|
||||
// null). The wrapper queues these calls until consent + init
|
||||
// resolve, so the order is safe even on a cold load.
|
||||
const lastUserIdRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const uid = me?.authenticated ? me.user?.id : null
|
||||
const viewer = me?.authenticated ? me.user : null
|
||||
if (uid != null && lastUserIdRef.current !== uid) {
|
||||
lastUserIdRef.current = uid
|
||||
const props = {}
|
||||
if (viewer?.role != null) props.role = viewer.role
|
||||
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
|
||||
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
|
||||
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
|
||||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||
identify({ user_id: String(uid), properties: props })
|
||||
identify({ user_id: String(uid) })
|
||||
} else if (uid == null && lastUserIdRef.current != null) {
|
||||
// Sign-out edge — App-level reset is handled separately by the
|
||||
// sign-out gesture that fires User Signed Out. Clear our local
|
||||
// memo so a fresh sign-in re-fires identify.
|
||||
lastUserIdRef.current = null
|
||||
}
|
||||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||
}, [me?.authenticated, me?.user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
@@ -222,16 +200,6 @@ export default function App() {
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
|
||||
Anonymous viewers see a sign-in prompt; signed-in users
|
||||
see the preview + accept gesture. */}
|
||||
<Route path="/invitations/accept" element={
|
||||
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
|
||||
} />
|
||||
{/* v0.17.0 — roadmap item #16. The claim landing page for
|
||||
admin-issued invites. Anonymous-reachable; the call
|
||||
itself establishes the session on success. */}
|
||||
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||
|
||||
@@ -322,48 +322,6 @@ export async function resolveThread(slug, branch, threadId) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ──────
|
||||
//
|
||||
// roadmap item #12 / §6 / §10. The RFC's owner invites specific emails
|
||||
// to one of two per-RFC roles ('contributor' or 'discussant'); the
|
||||
// invitee accepts via the email-encoded token after signing in. The
|
||||
// platform-level grant remains the admin's decision (per item #6 /
|
||||
// v0.8.0) — these endpoints control per-RFC membership only.
|
||||
|
||||
export async function listRFCInvitations(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`))
|
||||
}
|
||||
|
||||
export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/invitations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function revokeRFCInvitation(slug, invitationId) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function previewInvitation(token) {
|
||||
const params = new URLSearchParams({ token })
|
||||
return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`))
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token) {
|
||||
const res = await fetch('/api/invitations/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
||||
//
|
||||
// The substrate is `threads.branch_name IS NULL` — the same threads
|
||||
@@ -799,53 +757,6 @@ export async function removeAllowlistEmail(email) {
|
||||
}))
|
||||
}
|
||||
|
||||
// v0.17.0 — roadmap item #16. Admin-create user + invite email with
|
||||
// optional custom message. The frontend modal on /admin/users wires
|
||||
// these two helpers; the claim helper drives the /invites/claim page
|
||||
// that the invitee lands on when they click the email link.
|
||||
//
|
||||
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
|
||||
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
|
||||
// owner-grant-by-non-owner, malformed input) surface as thrown errors
|
||||
// via `jsonOrThrow` so the modal can render the server's message.
|
||||
//
|
||||
// `listUserInvites` returns the active-invites list for the admin's
|
||||
// "I sent these but they haven't been claimed yet" view. Active means
|
||||
// not claimed and not expired; once the invitee clicks through, the
|
||||
// row clears here and the user-listing's `pending_invite` badge
|
||||
// vanishes alongside.
|
||||
|
||||
export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
|
||||
return jsonOrThrow(await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
first_name: first_name || '',
|
||||
last_name: last_name || '',
|
||||
role,
|
||||
custom_message: custom_message || '',
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listUserInvites() {
|
||||
return jsonOrThrow(await fetch('/api/admin/users/invites'))
|
||||
}
|
||||
|
||||
// Claim an admin-issued invite token. Anonymous endpoint — the invitee
|
||||
// is not yet signed in; this call establishes the session on success.
|
||||
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
|
||||
// the server mints a fresh device-trust row + sets the long-lived
|
||||
// cookie so the invitee skips OTC on their next visit.
|
||||
export async function claimInvite(token, { trustDevice = false } = {}) {
|
||||
return jsonOrThrow(await fetch('/api/invites/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, trust_device: !!trustDevice }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function searchUsers(q) {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// AcceptInvitation.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The /invitations/accept?token=... landing page the invitation email
|
||||
// links to. The page:
|
||||
//
|
||||
// 1. Reads `?token=...` from the URL.
|
||||
// 2. Calls GET /api/invitations/accept?token=... to preview what the
|
||||
// invitation grants (RFC title, role-in-RFC, expiry, whether the
|
||||
// currently-signed-in user's email matches the invitee's).
|
||||
// 3. Renders a confirmation surface — name the RFC, name the role,
|
||||
// and either show "Accept" (when the email matches and the
|
||||
// invitation is still pending) or a refusal message (expired,
|
||||
// revoked, email mismatch).
|
||||
// 4. On accept, POST /api/invitations/accept lands the
|
||||
// rfc_collaborators row and the page redirects to the RFC's view.
|
||||
//
|
||||
// For an anonymous viewer who lands here without signing in, the
|
||||
// preview call 401s and the page tells them to sign in. After
|
||||
// signing in (via the existing OTC/passcode surface at /login) they
|
||||
// can return to the same URL — the token is stable.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { acceptInvitation, previewInvitation } from '../api'
|
||||
import { EVENTS, identify, track } from '../lib/analytics'
|
||||
|
||||
export default function AcceptInvitation({ viewer }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [acceptError, setAcceptError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPreviewError('No invitation token in the URL.')
|
||||
return
|
||||
}
|
||||
if (!viewer) {
|
||||
// Not signed in — the preview endpoint will 401. We surface a
|
||||
// sign-in prompt without making the request.
|
||||
return
|
||||
}
|
||||
previewInvitation(token)
|
||||
.then(setPreview)
|
||||
.catch(err => setPreviewError(err.message || 'Could not load invitation.'))
|
||||
}, [token, viewer])
|
||||
|
||||
async function handleAccept() {
|
||||
setAccepting(true)
|
||||
setAcceptError(null)
|
||||
try {
|
||||
const result = await acceptInvitation(token)
|
||||
// v0.16.0 + #21 Part C — re-identify with per-RFC invite
|
||||
// properties on accept, BEFORE the track event fires, so the
|
||||
// Amplitude user record carries the invite context from the
|
||||
// moment of acceptance. setOnce on invited_at preserves the
|
||||
// first-accepted timestamp if the same user accepts multiple
|
||||
// RFC invitations.
|
||||
if (viewer?.id != null) {
|
||||
identify({
|
||||
user_id: String(viewer.id),
|
||||
properties: {
|
||||
invited_at: ['__setOnce__', new Date().toISOString()],
|
||||
last_invited_to_rfc: result.rfc_slug,
|
||||
last_invite_role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
claim_method: 'rfc-invite',
|
||||
},
|
||||
})
|
||||
}
|
||||
track(EVENTS.INVITATION_ACCEPTED, {
|
||||
rfc_slug: result.rfc_slug,
|
||||
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
})
|
||||
navigate(`/rfc/${result.rfc_slug}`)
|
||||
} catch (err) {
|
||||
setAcceptError(err.message || 'Could not accept invitation.')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation link is malformed</h1>
|
||||
<p>No <code>token</code> parameter was found. Ask the person who
|
||||
invited you to re-send the link.</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Sign in to accept your invitation</h1>
|
||||
<p>
|
||||
You've been invited to collaborate on an RFC. Sign in first so we
|
||||
can attach the membership to your account, then return to this
|
||||
link.
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/login" className="btn-primary">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation unavailable</h1>
|
||||
<p>{previewError}</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return <div className="accept-invitation">Loading invitation…</div>
|
||||
}
|
||||
|
||||
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
|
||||
|
||||
if (status === 'revoked') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation revoked</h1>
|
||||
<p>
|
||||
The owner of <strong>{rfc_title}</strong> revoked this invitation.
|
||||
Ask them to re-issue it if you should still have access.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation expired</h1>
|
||||
<p>
|
||||
This invitation to <strong>{rfc_title}</strong> has expired. Ask
|
||||
the RFC's owner to issue a fresh one.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'accepted') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Already accepted</h1>
|
||||
<p>
|
||||
You've already accepted this invitation. You can{' '}
|
||||
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!email_matches_you) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>This invitation is for a different account</h1>
|
||||
<p>
|
||||
This invitation was sent to <strong>{invitee_email}</strong>. You're
|
||||
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
|
||||
Sign out and sign back in with the invited address to accept.
|
||||
</p>
|
||||
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Join {rfc_title}</h1>
|
||||
<p>
|
||||
You've been invited to <strong>{rfc_title}</strong> as a{' '}
|
||||
<strong>{role_in_rfc}</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#666' }}>
|
||||
{role_in_rfc === 'contributor'
|
||||
? 'Contributors can open PRs against this RFC and join its discussion.'
|
||||
: 'Discussants can post in this RFC\'s discussion.'}
|
||||
</p>
|
||||
{acceptError && <div className="error-banner">{acceptError}</div>}
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,16 +23,9 @@ import {
|
||||
listAllowlist,
|
||||
addAllowlistEmail,
|
||||
removeAllowlistEmail,
|
||||
createUserInvite,
|
||||
} from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics.js'
|
||||
|
||||
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||||
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||||
// here so the modal's "remaining chars" counter stays in lockstep
|
||||
// with the server-side bound.
|
||||
const CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||
|
||||
const TABS = [
|
||||
{ path: 'users', label: 'Users' },
|
||||
{ path: 'allowlist', label: 'Allowlist' },
|
||||
@@ -97,10 +90,6 @@ function UsersTab() {
|
||||
const [busy, setBusy] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
const [stateFilter, setStateFilter] = useState('all')
|
||||
// v0.17.0 — roadmap item #16. The "Create user + invite" modal's
|
||||
// open/closed state. The modal is local to UsersTab (it only opens
|
||||
// from the header button) and refreshes the listing on success.
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||
|
||||
async function refresh() {
|
||||
setError(null)
|
||||
@@ -190,28 +179,8 @@ function UsersTab() {
|
||||
retain their v0.7.0 semantics — promote to admin to remove a
|
||||
user's ability to write without silencing them.
|
||||
</p>
|
||||
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||||
affordance opens a modal that provisions a fresh users row
|
||||
with the chosen role and sends an invite email with a
|
||||
single-use claim link. */}
|
||||
<div className="admin-tab-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => setInviteModalOpen(true)}
|
||||
>Create user + invite</button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
{inviteModalOpen && (
|
||||
<CreateUserInviteModal
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setInviteModalOpen(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="admin-filter-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
@@ -262,26 +231,12 @@ 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)
|
||||
// v0.17.0 — roadmap item #16. The user's row may also be the
|
||||
// "(pending invite)" shape: admin-created via POST /api/admin/users,
|
||||
// not yet claimed via /api/invites/claim. The backend's user-listing
|
||||
// surfaces this via `pending_invite` (object with invite_id +
|
||||
// expires_at) or null. The badge sits inline next to the handle so
|
||||
// the admin sees at a glance which rows are real users vs. unclaimed
|
||||
// invites.
|
||||
const pendingInvite = u.pending_invite
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">{handle}</span>
|
||||
{pendingInvite && (
|
||||
<span
|
||||
className="invite-badge"
|
||||
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||
>(pending invite)</span>
|
||||
)}
|
||||
<span className="muted">
|
||||
{fullName || u.display_name}
|
||||
{u.email ? ` · ${u.email}` : ''}
|
||||
@@ -371,173 +326,6 @@ function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
|
||||
//
|
||||
// The "Create user + invite" affordance on the Users tab opens this
|
||||
// modal. Admin types email, first name, last name, role, and (optionally)
|
||||
// a custom message to embed in the invite email. On submit, calls
|
||||
// `POST /api/admin/users` which provisions the row + sends the email.
|
||||
// The 409 path (duplicate email) and 422 path (self-invite, owner-
|
||||
// grant-by-non-owner, malformed input) surface the server's message
|
||||
// inline; the success path closes the modal and refreshes the listing.
|
||||
//
|
||||
// The modal lives in this file rather than a separate component
|
||||
// because it has one caller (UsersTab), reuses the existing modal
|
||||
// stylesheet from /admin's chrome, and shares the
|
||||
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
|
||||
|
||||
function CreateUserInviteModal({ onClose, onSuccess }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [role, setRole] = useState('contributor')
|
||||
const [customMessage, setCustomMessage] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
|
||||
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
const trimmedEmail = email.trim()
|
||||
if (!trimmedEmail) {
|
||||
setError('Email is required')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const result = await createUserInvite({
|
||||
email: trimmedEmail,
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
role,
|
||||
custom_message: customMessage,
|
||||
})
|
||||
// v0.17.0 + #21 Part C — Amplitude wiring. target_user_id is
|
||||
// the OHM user id the invite-create gesture provisioned;
|
||||
// initial_role is what the invitee inherits on claim.
|
||||
// custom_message_chars is a coarse signal of admin effort
|
||||
// (0 = template-only, 1+ = personalized). No PII.
|
||||
track(EVENTS.USER_INVITED, {
|
||||
target_user_id: result.invited_user_id != null
|
||||
? String(result.invited_user_id) : null,
|
||||
initial_role: result.role,
|
||||
custom_message_chars: (customMessage || '').length,
|
||||
})
|
||||
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
|
||||
// Brief delay so the admin sees the success state, then close
|
||||
// and let the parent refresh the listing.
|
||||
setTimeout(() => { onSuccess?.() }, 600)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Unable to send invite')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-panel" onClick={e => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h3>Create user + invite</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
>×</button>
|
||||
</header>
|
||||
<p className="muted">
|
||||
Provisions a fresh user row with the chosen role and sends an
|
||||
invite email carrying a single-use claim link. The link
|
||||
expires in 7 days. The invitee clicks through to claim
|
||||
their account — no OTC roundtrip is required on first sign-in.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="create-user-invite-form">
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
maxLength={320}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-row">
|
||||
<label>
|
||||
<span>First name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Last name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Role</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner (owner-only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
Custom message (optional){' '}
|
||||
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
value={customMessage}
|
||||
onChange={e => setCustomMessage(e.target.value)}
|
||||
disabled={busy}
|
||||
rows={4}
|
||||
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
|
||||
placeholder="Optional — embedded in the invite email."
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
{success && <p className="settings-note success">{success}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={busy || !email.trim() || remaining < 0}
|
||||
>
|
||||
{busy ? 'Sending…' : 'Send invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
|
||||
|
||||
function AllowlistTab() {
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// InvitationsModal.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The RFC owner's surface for issuing per-RFC invitations and watching
|
||||
// who has accepted. Opens from the RFC view's header strip when the
|
||||
// viewer is the RFC's owner (or a platform admin/owner). Non-owner
|
||||
// viewers never see the trigger.
|
||||
//
|
||||
// The modal shows two stacked sections:
|
||||
//
|
||||
// 1. "Invite someone" — email input + role picker
|
||||
// (contributor | discussant) + Send. The send goes through the
|
||||
// backend's POST /api/rfcs/<slug>/invitations, which both writes
|
||||
// the row and dispatches the email to the invitee. Success
|
||||
// refreshes the list below and clears the input.
|
||||
//
|
||||
// 2. "Existing invitations" — every invitation (pending +
|
||||
// accepted + revoked + expired) on this RFC, with revoke
|
||||
// buttons on the pending ones. The status of each row is the
|
||||
// effective status (the backend recomputes expired-from-pending
|
||||
// at read time so an unattended cron isn't required).
|
||||
//
|
||||
// No custom-message field — that belongs to item #16's platform-
|
||||
// level surface, not here. No bulk-invite — one email at a time
|
||||
// keeps the gesture deliberate.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
createRFCInvitation,
|
||||
listRFCInvitations,
|
||||
revokeRFCInvitation,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'Contributor — can open PRs and join discussion' },
|
||||
{ value: 'discussant', label: 'Discussant — can join discussion only' },
|
||||
]
|
||||
|
||||
export default function InvitationsModal({ slug, rfcTitle, onClose }) {
|
||||
const [invitations, setInvitations] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [inviteeEmail, setInviteeEmail] = useState('')
|
||||
const [roleInRFC, setRoleInRFC] = useState('contributor')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(null)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(null)
|
||||
const [revokingId, setRevokingId] = useState(null)
|
||||
|
||||
async function refresh() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const r = await listRFCInvitations(slug)
|
||||
setInvitations(r.items || [])
|
||||
} catch (e) {
|
||||
setLoadError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [slug])
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault()
|
||||
const email = inviteeEmail.trim()
|
||||
if (!email) return
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(null)
|
||||
try {
|
||||
await createRFCInvitation(slug, { inviteeEmail: email, roleInRFC })
|
||||
// v0.16.0 + #21 Part C — Amplitude wiring. No PII (the email
|
||||
// is the inviter's input, not the invitee's identity in our
|
||||
// analytics; we record the rfc_slug + role_in_rfc so a future
|
||||
// invite→accept correlation has both halves).
|
||||
track(EVENTS.INVITATION_SENT, { rfc_slug: slug, role_in_rfc: roleInRFC })
|
||||
setSubmitSuccess(`Invitation sent to ${email}.`)
|
||||
setInviteeEmail('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to send invitation.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(invitationId) {
|
||||
setRevokingId(invitationId)
|
||||
try {
|
||||
await revokeRFCInvitation(slug, invitationId)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to revoke invitation.')
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Invitations — {rfcTitle || slug}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Invite people by email to contribute PRs against this RFC or to
|
||||
join its discussion. Anyone with the link can read this RFC;
|
||||
this surface controls who can <em>write</em>.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
|
||||
<label htmlFor="invitee-email">Invitee email</label>
|
||||
<input
|
||||
id="invitee-email"
|
||||
type="email"
|
||||
value={inviteeEmail}
|
||||
onChange={e => setInviteeEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
|
||||
<select
|
||||
id="invitee-role"
|
||||
value={roleInRFC}
|
||||
onChange={e => setRoleInRFC(e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send invitation'}
|
||||
</button>
|
||||
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
|
||||
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr style={{ margin: '20px 0' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
|
||||
{loadError && <div className="error-banner">{loadError}</div>}
|
||||
{invitations === null && <div>Loading…</div>}
|
||||
{invitations !== null && invitations.length === 0 && (
|
||||
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
|
||||
)}
|
||||
{invitations !== null && invitations.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
|
||||
<th style={{ padding: 4 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map(inv => (
|
||||
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
|
||||
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
|
||||
<td style={{ padding: 4 }}>
|
||||
<span className={`invitation-status status-${inv.status}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
{inv.status === 'accepted' && inv.accepted_by_display && (
|
||||
<span style={{ color: '#666', marginLeft: 6 }}>
|
||||
by {inv.accepted_by_display}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 4, color: '#666' }}>
|
||||
{inv.created_at?.slice(0, 10) || ''}
|
||||
</td>
|
||||
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleRevoke(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// v0.17.0 — roadmap item #16. The claim flow's landing page.
|
||||
//
|
||||
// The admin's invite email carries a link to /invites/claim?token=…;
|
||||
// the invitee clicks through and lands here. The page reads the
|
||||
// token from the URL, posts it to /api/invites/claim, and on success
|
||||
// routes either to the passcode-set screen (if v0.10.0 passcode flow
|
||||
// is in play and the user has no passcode yet) or to home.
|
||||
//
|
||||
// Anonymous-reachable: the entire point of the call is to establish
|
||||
// the session; we do not pre-check authentication.
|
||||
//
|
||||
// Failure modes the backend distinguishes:
|
||||
// * 410 — token is expired or already claimed (the row is dead).
|
||||
// * 400 — token doesn't match any active invite (forged, revoked,
|
||||
// or wiped).
|
||||
//
|
||||
// We surface both as the same "this invite link isn't valid" shape
|
||||
// for the invitee — the detail message from the server reads
|
||||
// distinctively enough that the admin can debug from logs, and the
|
||||
// invitee just needs to know they should contact the admin for a
|
||||
// fresh link.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { claimInvite } from '../api.js'
|
||||
import { EVENTS, identify, track } from '../lib/analytics.js'
|
||||
|
||||
export default function InviteClaim() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
|
||||
const [error, setError] = useState(null)
|
||||
const [trustDevice, setTrustDevice] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [user, setUser] = useState(null)
|
||||
const [needsPasscode, setNeedsPasscode] = useState(false)
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
const token = params.get('token') || ''
|
||||
|
||||
async function performClaim() {
|
||||
if (!token) {
|
||||
setStatus('failed')
|
||||
setError('No invite token in the URL.')
|
||||
return
|
||||
}
|
||||
setSubmitted(true)
|
||||
setStatus('working')
|
||||
setError(null)
|
||||
try {
|
||||
const result = await claimInvite(token, { trustDevice })
|
||||
setUser(result.user)
|
||||
setNeedsPasscode(!!result.needs_passcode)
|
||||
// v0.17.0 + #21 Part C — identify the new user with their OHM
|
||||
// user_id BEFORE firing any track() event, so the Amplitude
|
||||
// user record is created with the OHM id from the first event
|
||||
// rather than as an anonymous device that retroactively links.
|
||||
// setOnce on invited_at + invited_by_admin_id + initial_role so
|
||||
// these are immutable user-history markers on the Amplitude
|
||||
// record.
|
||||
if (result.user?.id != null) {
|
||||
const setOnceProps = {
|
||||
claim_method: 'admin-invite',
|
||||
}
|
||||
if (result.invited_at) setOnceProps.invited_at = ['__setOnce__', result.invited_at]
|
||||
if (result.invited_by_admin_id != null) {
|
||||
setOnceProps.invited_by_admin_id = ['__setOnce__', String(result.invited_by_admin_id)]
|
||||
}
|
||||
if (result.user.role) setOnceProps.initial_role = ['__setOnce__', result.user.role]
|
||||
identify({
|
||||
user_id: String(result.user.id),
|
||||
properties: setOnceProps,
|
||||
})
|
||||
}
|
||||
track(EVENTS.INVITE_CLAIMED, {
|
||||
invited_by_admin_id: result.invited_by_admin_id != null
|
||||
? String(result.invited_by_admin_id) : null,
|
||||
initial_role: result.user?.role,
|
||||
needs_passcode: !!result.needs_passcode,
|
||||
trust_device: trustDevice,
|
||||
})
|
||||
setStatus('ok')
|
||||
} catch (e) {
|
||||
setStatus('failed')
|
||||
setError(e.message || 'Unable to claim invite')
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: if the URL has no token at all, fail fast so the
|
||||
// invitee sees the missing-token shape immediately rather than
|
||||
// an empty form.
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('failed')
|
||||
setError('This claim link is missing its token.')
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// On a successful claim, route the user onward. The brief calls
|
||||
// this out: route to passcode-set if v0.10.0 passcode flow is in
|
||||
// play and the user has no passcode yet; otherwise route to home.
|
||||
useEffect(() => {
|
||||
if (status !== 'ok') return
|
||||
const timeout = setTimeout(() => {
|
||||
if (needsPasscode) {
|
||||
navigate('/settings/notifications#sign-in', { replace: true })
|
||||
} else {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, 1200)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [status, needsPasscode, navigate])
|
||||
|
||||
return (
|
||||
<div className="invite-claim-page">
|
||||
<div className="invite-claim-panel">
|
||||
<h1>Claim your account</h1>
|
||||
{!submitted && status === 'working' && token && (
|
||||
<>
|
||||
<p>
|
||||
You've been invited to this deployment. Click the button below
|
||||
to claim your account and sign in. This link is single-use and
|
||||
expires 7 days after it was sent.
|
||||
</p>
|
||||
<label className="claim-trust-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={trustDevice}
|
||||
onChange={e => setTrustDevice(e.target.checked)}
|
||||
/>
|
||||
{' '}Trust this device for 30 days (skip the email step on
|
||||
your next visit from this browser).
|
||||
</label>
|
||||
<div className="claim-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={performClaim}
|
||||
>Claim my account</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{submitted && status === 'working' && (
|
||||
<p>Claiming…</p>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<>
|
||||
<p className="settings-note success">
|
||||
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
|
||||
You're signed in.
|
||||
</p>
|
||||
<p className="muted">
|
||||
{needsPasscode
|
||||
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
|
||||
: 'Redirecting you to the home page…'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<>
|
||||
<p className="settings-note warning">
|
||||
{error || "This invite link isn't valid."}
|
||||
</p>
|
||||
<p className="muted">
|
||||
If you believe this is a mistake, contact the admin who
|
||||
sent you the invite — they can issue a fresh link.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
import InvitationsModal from './InvitationsModal.jsx'
|
||||
import { claimOwnership } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
@@ -149,11 +148,6 @@ export default function RFCView({ viewer }) {
|
||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
||||
const [claimError, setClaimError] = useState(null)
|
||||
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
|
||||
// RFC owners (frontmatter) and platform admin/owner — the backend
|
||||
// gates the underlying endpoints regardless, so a leaked toggle
|
||||
// can't actually leak anything.
|
||||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
@@ -639,20 +633,6 @@ export default function RFCView({ viewer }) {
|
||||
Graduate to RFC repo
|
||||
</button>
|
||||
)}
|
||||
{/* v0.16.0 (item #12): owner-only invitations affordance.
|
||||
Shown when the viewer is named in the RFC's frontmatter
|
||||
`owners` list or holds a platform admin/owner role.
|
||||
Available on both super-drafts and active RFCs. */}
|
||||
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowInvitationsModal(true)}
|
||||
title="Invite collaborators to this RFC"
|
||||
>
|
||||
Invitations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{claimError && (
|
||||
@@ -885,14 +865,6 @@ export default function RFCView({ viewer }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInvitationsModal && (
|
||||
<InvitationsModal
|
||||
slug={slug}
|
||||
rfcTitle={entry?.title}
|
||||
onClose={() => setShowInvitationsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMetadataPane && (
|
||||
<MetadataPaneModal
|
||||
slug={slug}
|
||||
|
||||
+13
-110
@@ -91,12 +91,6 @@ export const EVENTS = Object.freeze({
|
||||
COMMENT_POSTED: 'Comment Posted',
|
||||
BETA_ACCESS_REQUESTED: 'Beta Access Requested',
|
||||
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision',
|
||||
// v0.16.0 / item #12 — per-RFC owner invites.
|
||||
INVITATION_SENT: 'Invitation Sent',
|
||||
INVITATION_ACCEPTED: 'Invitation Accepted',
|
||||
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||
USER_INVITED: 'User Invited',
|
||||
INVITE_CLAIMED: 'Invite Claimed',
|
||||
})
|
||||
|
||||
// Internal state.
|
||||
@@ -106,10 +100,7 @@ let _initPromise = null // Pending init (lazy import + sdk.init).
|
||||
let _initialized = false // True after sdk.init has resolved.
|
||||
let _warnedNoKey = false
|
||||
let _pendingUserId = null // identify() called before init resolves.
|
||||
let _pendingProperties = null // identify({ properties }) or
|
||||
// setUserProperties() before init.
|
||||
const _queue = [] // {kind: 'track'|'identify'|'anonymize'|
|
||||
// 'setUserProperties', ...}
|
||||
const _queue = [] // {kind: 'track'|'identify'|'anonymize', ...}
|
||||
|
||||
function warnNoKey() {
|
||||
if (_warnedNoKey) return
|
||||
@@ -128,26 +119,6 @@ function consentGranted() {
|
||||
return !!(c && c.recorded_at && c.analytics)
|
||||
}
|
||||
|
||||
// Apply a {key: value} property bag as an Amplitude Identify event.
|
||||
// Used by both `identify({ properties })` and `setUserProperties`.
|
||||
function applyProperties(props) {
|
||||
if (!_initialized || !_amplitude || !props) return
|
||||
try {
|
||||
const id = new _amplitude.Identify()
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined || v === null) continue
|
||||
if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') {
|
||||
id.setOnce(k, v[1])
|
||||
} else {
|
||||
id.set(k, v)
|
||||
}
|
||||
}
|
||||
_amplitude.identify(id)
|
||||
} catch (_) {
|
||||
// SDK errors are non-fatal; analytics is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the queue. Called once init resolves.
|
||||
function drainQueue() {
|
||||
if (!_initialized || !_amplitude) return
|
||||
@@ -155,10 +126,6 @@ function drainQueue() {
|
||||
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
|
||||
_pendingUserId = null
|
||||
}
|
||||
if (_pendingProperties != null) {
|
||||
applyProperties(_pendingProperties)
|
||||
_pendingProperties = null
|
||||
}
|
||||
while (_queue.length > 0) {
|
||||
const item = _queue.shift()
|
||||
try {
|
||||
@@ -166,9 +133,6 @@ function drainQueue() {
|
||||
_amplitude.track(item.name, item.props || {})
|
||||
} else if (item.kind === 'identify') {
|
||||
if (item.user_id != null) _amplitude.setUserId(item.user_id)
|
||||
if (item.properties != null) applyProperties(item.properties)
|
||||
} else if (item.kind === 'setUserProperties') {
|
||||
applyProperties(item.properties)
|
||||
} else if (item.kind === 'anonymize') {
|
||||
_amplitude.reset()
|
||||
}
|
||||
@@ -273,93 +237,33 @@ export function track(name, props) {
|
||||
_queue.push({ kind: 'track', name, props })
|
||||
}
|
||||
|
||||
/** Attach an authenticated user id and optional durable properties.
|
||||
* Pass `{ user_id: '<opaque-id>', properties?: { role, first_sign_in_at, … } }`.
|
||||
* DO NOT pass email, display name, or other PII as user_id or in
|
||||
* properties. Idempotent — subsequent calls with the same id are
|
||||
* cheap; properties are merged into the Amplitude user record.
|
||||
*
|
||||
* To mark a property as setOnce (immutable after first write),
|
||||
* pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`.
|
||||
* Bare values use Amplitude's `.set()` (mutable).
|
||||
*
|
||||
* Pattern (per #21 Part C):
|
||||
* - On sign-in success in App.jsx: identify with viewer.id + the
|
||||
* durable property bag (role, permission_state, first_sign_in_at
|
||||
* setOnce, passcode_set, device_trusted_count, account_created_at
|
||||
* setOnce).
|
||||
* - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx:
|
||||
* identify with the new viewer.id + invitation-derived properties
|
||||
* (invited_by_admin_id, invited_at setOnce, initial_role, claim_method)
|
||||
* BEFORE firing any track() — so the Amplitude user record is
|
||||
* created with the OHM user_id from the first event, not as an
|
||||
* anonymous device that retroactively links. */
|
||||
export function identify({ user_id, properties } = {}) {
|
||||
/** Attach an authenticated user id. Pass `{ user_id: '<opaque-id>' }`.
|
||||
* DO NOT pass email or display name. Idempotent — subsequent calls
|
||||
* with the same id are cheap. */
|
||||
export function identify({ user_id } = {}) {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
if (user_id == null && properties == null) return
|
||||
if (user_id == null) return
|
||||
bootstrap()
|
||||
if (!consentGranted()) {
|
||||
// Hold for when consent lands; identify-on-sign-in is a common
|
||||
// race with the consent banner choice.
|
||||
if (user_id != null) _pendingUserId = user_id
|
||||
if (properties != null) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
}
|
||||
// Hold the id for when consent lands; identify-on-sign-in is a
|
||||
// common race with the consent banner choice.
|
||||
_pendingUserId = user_id
|
||||
return
|
||||
}
|
||||
if (_initialized && _amplitude) {
|
||||
try {
|
||||
if (user_id != null) _amplitude.setUserId(user_id)
|
||||
if (properties != null) applyProperties(properties)
|
||||
} catch (_) {}
|
||||
try { _amplitude.setUserId(user_id) } catch (_) {}
|
||||
return
|
||||
}
|
||||
if (user_id != null) _pendingUserId = user_id
|
||||
if (properties != null) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
}
|
||||
_queue.push({ kind: 'identify', user_id, properties })
|
||||
}
|
||||
|
||||
/** Update durable user properties on the current Amplitude user
|
||||
* record mid-session — for state changes that shouldn't wait for the
|
||||
* next sign-in to surface (role grant/revoke, passcode set, device
|
||||
* trusted, etc.). Same property shape as `identify({ properties })`.
|
||||
* setOnce values use the `['__setOnce__', value]` sentinel pattern.
|
||||
* Has no effect if no identify has happened yet — set the user_id
|
||||
* via `identify()` first.
|
||||
*
|
||||
* Per #21 Part C: call this from any surface where the user's
|
||||
* Amplitude-relevant state changes mid-session, so the dashboard
|
||||
* stays current. */
|
||||
export function setUserProperties(properties) {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
if (properties == null) return
|
||||
bootstrap()
|
||||
if (!consentGranted()) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
return
|
||||
}
|
||||
if (_initialized && _amplitude) {
|
||||
applyProperties(properties)
|
||||
return
|
||||
}
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
_queue.push({ kind: 'setUserProperties', properties })
|
||||
_pendingUserId = user_id
|
||||
_queue.push({ kind: 'identify', user_id })
|
||||
}
|
||||
|
||||
/** Reset the user binding. Call this on sign-out so the next page
|
||||
* navigations are attributed to a fresh anonymous device id. Has
|
||||
* no effect when analytics is disabled.
|
||||
*
|
||||
* Per #21 Part C: clears both the user_id binding AND the pending
|
||||
* property cache, so a subsequent sign-in as a different user
|
||||
* starts with a fully fresh slate (no carry-over properties from
|
||||
* the previous user). */
|
||||
* no effect when analytics is disabled. */
|
||||
export function anonymize() {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
_pendingUserId = null
|
||||
_pendingProperties = null
|
||||
bootstrap()
|
||||
if (!consentGranted()) return
|
||||
if (_initialized && _amplitude) {
|
||||
@@ -379,6 +283,5 @@ export function __resetForTests() {
|
||||
_initialized = false
|
||||
_warnedNoKey = false
|
||||
_pendingUserId = null
|
||||
_pendingProperties = null
|
||||
_queue.length = 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user