Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0562d53f86 | |||
| 4666c4abe7 | |||
| 281a844513 | |||
| d3daa97264 | |||
| e9fdc478f6 | |||
| 92059f319e | |||
| 1456c8b73f |
+382
@@ -23,6 +23,388 @@ 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.18.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; one env var now mandatory; no
|
||||
new secrets.** This release lands the framework-side half of OHM
|
||||
roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20
|
||||
(Email deliverability). It is the framework counterpart to the
|
||||
operator-side SMTP / DNS hardening covered in the
|
||||
`EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` companion doc.
|
||||
|
||||
The release is shipped in five atomic slices per the v0.18.0 proposal:
|
||||
|
||||
1. **`build_envelope` shared helper.** A single place where every
|
||||
outbound `EmailMessage` is constructed. Lands the
|
||||
deliverability-critical headers (`Date`, `Message-ID`,
|
||||
`Auto-Submitted`) uniformly across OTC, invite, watcher
|
||||
notification, bundle, and digest paths; exposes per-kind
|
||||
unsubscribe semantics (none for OTC, mailto: for invites, full
|
||||
one-click for bulk-adjacent paths) as explicit kwargs.
|
||||
2. **Migrated send paths.** `email_otc.py`, `email_invite.py`,
|
||||
`email._deliver`, `email._send_bundle`, and `digest.py` now
|
||||
build their envelopes through the helper. Per-RFC invite
|
||||
(v0.16.0) rides through `email_invite.py`'s helper and picks
|
||||
up the change for free. The shared `_SENT` test buffer also
|
||||
carries the constructed `EmailMessage` under `envelope["message"]`
|
||||
so tests can assert on the header surface directly.
|
||||
3. **Webhook handler tightening.** `GITEA_WEBHOOK_SECRET` is now
|
||||
mandatory at startup — the framework refuses to load_config()
|
||||
if it's empty, unless the operator explicitly opts into the
|
||||
dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`. The
|
||||
`/api/webhooks/gitea` receiver carries defense-in-depth checks
|
||||
that surface the misconfiguration loudly at the request layer
|
||||
too. Mis-targeted webhooks (a hook on a fork or a stale Gitea
|
||||
binding) now log at INFO instead of silently 200-OK'ing.
|
||||
4. **`outbound_emails` audit table + admin endpoint.** Every send
|
||||
helper writes one row to `outbound_emails` before returning,
|
||||
capturing the send attempt regardless of outcome
|
||||
(status='sent' / 'failed' / 'deferred'). The new admin endpoint
|
||||
`GET /api/admin/outbound-emails` (filterable by kind / status /
|
||||
to_address) lets the operator answer "did this person ever get
|
||||
their invite?" without grepping VM logs. No admin UI ships
|
||||
with v0.18.0; operator queries via curl + jq for now.
|
||||
5. **Bounce correlation.** The `POST /api/webhooks/email-bounce`
|
||||
body accepts a new optional `message_id` field; when supplied,
|
||||
the handler stamps status='bounced' on the matching
|
||||
`outbound_emails` row and returns the row id as
|
||||
`correlated_id`. The pre-existing hard-bounce ->
|
||||
`email_opt_out_all = 1` flow still fires.
|
||||
|
||||
The `POST /api/email/unsubscribe` endpoint also lands in Slice 2
|
||||
as the matching receiver for the new `List-Unsubscribe-Post:
|
||||
List-Unsubscribe=One-Click` header (Gmail and Yahoo POST that
|
||||
payload on the user's one-click action per RFC 8058 — the GET
|
||||
endpoint alone is no longer sufficient for senders at OHM's tier).
|
||||
|
||||
### Added
|
||||
|
||||
- **`backend/app/email_envelope.py`** — the `build_envelope` helper.
|
||||
Single source of truth for every outbound `EmailMessage`'s
|
||||
headers + body shape. Standalone module so tests can exercise it
|
||||
without booting the FastAPI app.
|
||||
- **`backend/migrations/020_outbound_emails.sql`** — the audit
|
||||
table. Single new table with three indexes (to_address, sent_at,
|
||||
message_id); no changes to existing tables.
|
||||
- **`email.record_outbound(...)`** — best-effort write helper every
|
||||
send path calls. Catches `RuntimeError` (so pure-helper unit
|
||||
tests where `db.init()` was never called don't break) and any
|
||||
other exception (so the audit write never breaks a send).
|
||||
- **`GET /api/admin/outbound-emails`** in `api_admin.py` —
|
||||
admin-only listing of `outbound_emails`. Newest-first, filterable
|
||||
by kind, status, and to_address (case-insensitive). Returns
|
||||
`{items: [...], has_more}` per the rest of the admin endpoints'
|
||||
shape.
|
||||
- **`POST /api/email/unsubscribe`** in `api_notifications.py` —
|
||||
RFC 8058 one-click receiver. Accepts the same `?t=` token as the
|
||||
GET handler; idempotent; returns 200 + `{ok, category}` on
|
||||
success.
|
||||
- **`all` synthetic category** for unsubscribe URLs. Used by the
|
||||
bundle + digest paths (which can't honor per-category opt-outs
|
||||
because they span multiple categories); flips
|
||||
`email_opt_out_all = 1` rather than a per-category column.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/email.py`** — `EmailConfig` gains
|
||||
`unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`, default
|
||||
falls back to `EMAIL_FROM`). `_deliver` and `_send_bundle`
|
||||
build envelopes through `build_envelope` and thread `kind` +
|
||||
`notification_id` into the audit write.
|
||||
- **`backend/app/email_otc.py`** + **`email_invite.py`** — both
|
||||
call `build_envelope` and `record_outbound`. OTC carries no
|
||||
`List-Unsubscribe` (recipient explicitly requested the code);
|
||||
invite carries `List-Unsubscribe: <mailto:…>` only (no signed
|
||||
URL — the invitee isn't a user yet, no per-user opt-out row
|
||||
exists).
|
||||
- **`backend/app/digest.py`** — calls `_deliver` with `kind='digest'`
|
||||
+ the new `all`-category one-click unsubscribe.
|
||||
- **`backend/app/webhooks.py`** — refuses 500 at request time if
|
||||
the secret is empty + bypass isn't set; logs a loud warning
|
||||
per-request when running under the bypass; logs INFO when a
|
||||
hook targets a repo not in `cached_rfcs`.
|
||||
- **`backend/app/config.py`** — `load_config()` raises
|
||||
RuntimeError if `GITEA_WEBHOOK_SECRET` is empty unless
|
||||
`RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
- **`backend/app/api_notifications.py`** — GET unsubscribe handler
|
||||
accepts the `all` category (sets `email_opt_out_all = 1`).
|
||||
Bounce webhook body adds optional `message_id` field; response
|
||||
shape adds `correlated_id` field. (Tests that read the exact
|
||||
response shape — currently just
|
||||
`test_bounce_webhook_refuses_unsigned_when_secret_configured`
|
||||
in test_e2e_smoke.py — updated to assert on the new shape.)
|
||||
- **`backend/tests/test_propose_vertical.py`** — the shared
|
||||
`tmp_env` fixture binds a fake `GITEA_WEBHOOK_SECRET` so all
|
||||
252 pre-v0.18.0 tests boot cleanly under the new mandatory
|
||||
secret. Tests that want to exercise the dev-bypass path
|
||||
monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1` explicitly.
|
||||
|
||||
### Tests
|
||||
|
||||
- 15 new unit tests in `test_email_envelope.py` for the helper.
|
||||
- 10 new integration tests across `test_otc_vertical`,
|
||||
`test_admin_create_user_invite_vertical`, and
|
||||
`test_notifications_vertical` covering: OTC has no
|
||||
List-Unsubscribe; invite has mailto: only; notification has
|
||||
full one-click; POST one-click flips per-category; `all` flips
|
||||
global; respects `EMAIL_UNSUBSCRIBE_MAILTO` override.
|
||||
- 7 new integration tests in `test_webhooks_vertical.py` covering
|
||||
the startup-time mandatory-secret check, the dev-bypass, and
|
||||
the request-time signature verification including the unknown-
|
||||
repo log line.
|
||||
- 11 new integration tests in `test_outbound_emails_vertical.py`
|
||||
covering the audit table write path (OTC / invite / notification),
|
||||
the admin endpoint (list, filter by kind, filter by to_address,
|
||||
non-admin refusal), and the bounce correlation (matched
|
||||
message_id stamps status='bounced'; unknown message_id is
|
||||
logged; absent message_id falls back to legacy behavior; bounced
|
||||
rows surface in admin endpoint with `?status=bounced`).
|
||||
- One test updated for intentional response-shape change:
|
||||
`test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured`.
|
||||
|
||||
Full suite: 295 passed (was 252 pre-v0.18.0).
|
||||
|
||||
### Migration
|
||||
|
||||
- **`backend/migrations/020_outbound_emails.sql`** — auto-applied
|
||||
on next backend start. Single new table with three indexes; no
|
||||
changes to existing tables.
|
||||
|
||||
### Upgrade steps (from 0.17.0)
|
||||
|
||||
- Operators **MUST** ensure `GITEA_WEBHOOK_SECRET` is set in the
|
||||
deployment's env. The framework now refuses to start if it's
|
||||
empty. (For OHM-flotilla deployments,
|
||||
`flotilla secret list <deployment>` confirms the binding; OHM
|
||||
has carried this binding since v0.14.0, so the upgrade is
|
||||
gesture-free for OHM specifically.)
|
||||
- Operators **MAY** set `RFC_APP_INSECURE_WEBHOOKS=1` to bypass
|
||||
the requirement in local-dev environments. Production
|
||||
deployments **MUST NOT** set this; if they do, every webhook
|
||||
POST logs a loud warning line per request.
|
||||
- Operators **MAY** set `EMAIL_UNSUBSCRIBE_MAILTO` to route
|
||||
`List-Unsubscribe: <mailto:…>` opt-out courtesy mail to a
|
||||
humans-monitored mailbox distinct from the no-reply
|
||||
`EMAIL_FROM` sender. Default falls back to `EMAIL_FROM`.
|
||||
- You **MUST** apply schema migration
|
||||
`020_outbound_emails.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. Existing deployments pick it up on first start
|
||||
after upgrade with no operator action required.
|
||||
- You **MUST** rebuild the frontend and restart the backend
|
||||
after upgrading. `frontend/package.json#version` and `VERSION`
|
||||
both move to `0.18.0`. No new secrets (the `outbound_emails`
|
||||
table writes synchronously to the same SQLite file as every
|
||||
other write).
|
||||
- Operators **SHOULD** run a `mail-tester.com` probe against the
|
||||
upgraded deployment to confirm the new envelope headers
|
||||
(`Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`,
|
||||
`List-Unsubscribe-Post`) land cleanly with the upstream SMTP
|
||||
relay's DKIM signing. The expected delta from pre-v0.18.0 is
|
||||
+2-3 points on the spam-score axis (typical 5-6/10 baseline
|
||||
→ 9+/10 post-upgrade).
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
+332
-1
@@ -11,6 +11,8 @@ 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
|
||||
@@ -33,8 +35,9 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db
|
||||
from . import auth, db, email_invite, invites
|
||||
from .config import Config
|
||||
from .email import EmailConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,6 +68,32 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,6 +185,32 @@ def make_router(config: Config) -> APIRouter:
|
||||
"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": [
|
||||
{
|
||||
@@ -176,6 +231,215 @@ def make_router(config: Config) -> APIRouter:
|
||||
"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
|
||||
]
|
||||
@@ -408,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/outbound-emails")
|
||||
async def list_outbound_emails(
|
||||
request: Request,
|
||||
kind: str | None = None,
|
||||
status: str | None = None,
|
||||
to_address: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
before_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""v0.18.0 Slice 4: read-only inspection of the
|
||||
`outbound_emails` audit table.
|
||||
|
||||
Answers questions like "did this person ever get their
|
||||
invite?" without grepping VM logs. Filterable by kind
|
||||
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
|
||||
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
|
||||
to_address; the latter is exact-match because the audit
|
||||
question is usually "the specific person who said they
|
||||
didn't receive it." Per the proposal, no admin UI ships
|
||||
with v0.18.0 — operator queries via curl + jq for now.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
clauses: list[str] = []
|
||||
args: list[Any] = []
|
||||
if kind:
|
||||
clauses.append("kind = ?")
|
||||
args.append(kind)
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
args.append(status)
|
||||
if to_address:
|
||||
clauses.append("LOWER(to_address) = LOWER(?)")
|
||||
args.append(to_address)
|
||||
if before_id is not None:
|
||||
clauses.append("id < ?")
|
||||
args.append(before_id)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT id, to_address, from_address, subject, kind, sent_at,
|
||||
status, error, notification_id, message_id
|
||||
FROM outbound_emails
|
||||
{where}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"to_address": r["to_address"],
|
||||
"from_address": r["from_address"],
|
||||
"subject": r["subject"],
|
||||
"kind": r["kind"],
|
||||
"sent_at": r["sent_at"],
|
||||
"status": r["status"],
|
||||
"error": r["error"],
|
||||
"notification_id": r["notification_id"],
|
||||
"message_id": r["message_id"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/permission-events")
|
||||
async def list_permission_events(
|
||||
request: Request,
|
||||
|
||||
@@ -73,6 +73,13 @@ class MarkReadBody(BaseModel):
|
||||
class BounceBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
||||
# v0.18.0 Slice 5: when the bounce provider includes the
|
||||
# original Message-ID, the framework correlates it back to
|
||||
# the matching `outbound_emails` row and stamps
|
||||
# `status='bounced'`. Optional — providers that don't surface
|
||||
# the Message-ID still flip the global opt-out via the email
|
||||
# match, but lose the per-message attribution.
|
||||
message_id: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class CookieConsentBody(BaseModel):
|
||||
@@ -443,6 +450,40 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||
|
||||
# v0.18.0: the category → column map. The `all` synthetic
|
||||
# category lands the bundle's one-click on the global opt-out
|
||||
# flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
|
||||
# spans multiple categories, so a per-category flip wouldn't
|
||||
# honor the user's intent).
|
||||
_CATEGORY_COLUMN: dict[str, str] = {
|
||||
"personal-direct": "email_personal_direct",
|
||||
"structural": "email_watched_structural",
|
||||
"admin-actionable": "email_admin_actionable",
|
||||
"all": "email_opt_out_all",
|
||||
}
|
||||
|
||||
def _apply_unsubscribe(user_id: int, category: str) -> bool:
|
||||
"""Flip the matching column. Returns True on success, False
|
||||
if the category is unknown. Idempotent — running twice on
|
||||
the same (user, category) is harmless (it sets the column
|
||||
to its current value)."""
|
||||
column = _CATEGORY_COLUMN.get(category)
|
||||
if column is None:
|
||||
return False
|
||||
# `all` sets the flag to 1 (opt out); per-category sets to 0
|
||||
# (turn that category off). The column semantic is "1 means
|
||||
# don't send"; the per-category booleans are "1 means do
|
||||
# send". Different polarities, hence the case split.
|
||||
if category == "all":
|
||||
db.conn().execute(
|
||||
f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
|
||||
)
|
||||
else:
|
||||
db.conn().execute(
|
||||
f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
|
||||
)
|
||||
return True
|
||||
|
||||
@router.get("/api/email/unsubscribe")
|
||||
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
|
||||
try:
|
||||
@@ -453,20 +494,51 @@ def make_router(config: Config) -> APIRouter:
|
||||
"<p>Open the app to manage your notification preferences directly.</p>",
|
||||
status_code=400,
|
||||
)
|
||||
column = {
|
||||
"personal-direct": "email_personal_direct",
|
||||
"structural": "email_watched_structural",
|
||||
"admin-actionable": "email_admin_actionable",
|
||||
}.get(category)
|
||||
if column is None:
|
||||
if not _apply_unsubscribe(user_id, category):
|
||||
return HTMLResponse(
|
||||
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
|
||||
)
|
||||
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
|
||||
return HTMLResponse(
|
||||
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
|
||||
f"You can re-enable them in your notification preferences.</p>"
|
||||
)
|
||||
if category == "all":
|
||||
body = (
|
||||
"<h1>Unsubscribed</h1><p>You will no longer receive any email "
|
||||
"from this app. You can re-enable individual categories from "
|
||||
"your notification preferences after signing in.</p>"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
|
||||
f"You can re-enable them in your notification preferences.</p>"
|
||||
)
|
||||
return HTMLResponse(body)
|
||||
|
||||
@router.post("/api/email/unsubscribe")
|
||||
async def email_unsubscribe_post(
|
||||
request: Request,
|
||||
t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
|
||||
) -> dict[str, Any]:
|
||||
"""v0.18.0: RFC 8058 one-click endpoint.
|
||||
|
||||
Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
|
||||
form-encoded body) to the URL in the `List-Unsubscribe`
|
||||
header when the user clicks their MUA's "Unsubscribe"
|
||||
button. The endpoint MUST accept POST (per the
|
||||
`List-Unsubscribe-Post` header we advertise) and MUST be
|
||||
idempotent.
|
||||
|
||||
The body content is checked loosely — RFC 8058 says it
|
||||
SHOULD be exactly `List-Unsubscribe=One-Click`, but some
|
||||
intermediaries strip / re-encode the body, so the
|
||||
framework accepts any POST to the URL once the token
|
||||
verifies. The bar is that the token signature carries the
|
||||
authority; the body is hint-only.
|
||||
"""
|
||||
try:
|
||||
user_id, category = email_mod.verify_unsubscribe_token(t)
|
||||
except BadSignature:
|
||||
raise HTTPException(400, "Invalid or expired token")
|
||||
if not _apply_unsubscribe(user_id, category):
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
return {"ok": True, "category": category}
|
||||
|
||||
@router.post("/api/webhooks/email-bounce")
|
||||
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
|
||||
@@ -490,16 +562,46 @@ def make_router(config: Config) -> APIRouter:
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
raise HTTPException(401, "Invalid webhook signature")
|
||||
# v0.18.0 Slice 5: correlate the bounce back to the
|
||||
# matching outbound_emails row if the provider supplied
|
||||
# the Message-ID. The hard-bounce -> global-opt-out
|
||||
# logic below still fires regardless; this is an
|
||||
# additional audit signal.
|
||||
correlated_row_id: int | None = None
|
||||
if body.message_id:
|
||||
correlated = db.conn().execute(
|
||||
"SELECT id FROM outbound_emails WHERE message_id = ?",
|
||||
(body.message_id,),
|
||||
).fetchone()
|
||||
if correlated is not None:
|
||||
correlated_row_id = correlated["id"]
|
||||
db.conn().execute(
|
||||
"UPDATE outbound_emails SET status = 'bounced', "
|
||||
"error = COALESCE(error, '') || ? WHERE id = ?",
|
||||
(f"bounce ({body.kind})", correlated_row_id),
|
||||
)
|
||||
log.info(
|
||||
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
|
||||
body.message_id, correlated_row_id,
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"email-bounce: message_id=%s did not match any "
|
||||
"outbound_emails row (provider may be replaying an old bounce, "
|
||||
"or the row was pruned)",
|
||||
body.message_id,
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": True, "matched": False}
|
||||
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
|
||||
)
|
||||
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
|
||||
return {"ok": True, "matched": True}
|
||||
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
+15
-1
@@ -60,6 +60,20 @@ def load_config() -> Config:
|
||||
|
||||
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
|
||||
|
||||
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the
|
||||
# email + webhook hygiene proposal). An empty value used to
|
||||
# silently accept unsigned webhook POSTs — that was the
|
||||
# invisible-failure shape the proposal targets. Now the
|
||||
# framework refuses to start when the secret is empty unless
|
||||
# the operator opts into the dev-bypass with
|
||||
# `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without
|
||||
# a wired Gitea hook set the bypass; production MUST NOT.
|
||||
insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
|
||||
if insecure_webhooks:
|
||||
webhook_secret = _optional("GITEA_WEBHOOK_SECRET")
|
||||
else:
|
||||
webhook_secret = _required("GITEA_WEBHOOK_SECRET")
|
||||
|
||||
return Config(
|
||||
gitea_url=_required("GITEA_URL").rstrip("/"),
|
||||
gitea_bot_user=_required("GITEA_BOT_USER"),
|
||||
@@ -72,7 +86,7 @@ def load_config() -> Config:
|
||||
secret_key=_required("SECRET_KEY"),
|
||||
database_path=database_path,
|
||||
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
|
||||
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
|
||||
webhook_secret=webhook_secret,
|
||||
enabled_models=enabled,
|
||||
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
|
||||
google_api_key=_optional("GOOGLE_API_KEY"),
|
||||
|
||||
+13
-1
@@ -180,7 +180,19 @@ def assemble_for_user(
|
||||
|
||||
subject = _subject(eligible, cadence)
|
||||
body = _body(eligible, cadence, cfg)
|
||||
sent = email_mod._deliver(cfg, email, subject, body)
|
||||
# v0.18.0: the digest is the bulk-adjacent surface par excellence
|
||||
# (it can carry weeks of accumulated activity), so it gets the
|
||||
# full one-click unsubscribe to the global opt-out. Per-category
|
||||
# opt-outs are managed from the preferences page; this footer is
|
||||
# the "stop sending me anything" escape hatch Gmail and Yahoo
|
||||
# expect for senders at this tier.
|
||||
unsubscribe_url = email_mod.make_unsubscribe_url(user_id, "all")
|
||||
sent = email_mod._deliver(
|
||||
cfg, email, subject, body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="digest",
|
||||
)
|
||||
if not sent:
|
||||
return False
|
||||
ids = [r["id"] for r, _ in eligible]
|
||||
|
||||
+165
-10
@@ -24,7 +24,6 @@ import os
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from itertools import groupby
|
||||
from typing import Any
|
||||
@@ -33,6 +32,7 @@ from urllib.parse import urlencode
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
from . import db
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,6 +69,7 @@ class EmailConfig:
|
||||
app_url: str
|
||||
bundle_threshold: int
|
||||
enabled: bool
|
||||
unsubscribe_mailto: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "EmailConfig":
|
||||
@@ -84,6 +85,16 @@ class EmailConfig:
|
||||
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
|
||||
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
|
||||
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
|
||||
# v0.18.0: the `List-Unsubscribe: <mailto:…>` target on
|
||||
# invite + notification mail. Defaults to the From
|
||||
# address when unset; a deployment can route opt-out
|
||||
# mail to a separate mailbox (e.g., a humans-monitored
|
||||
# account distinct from the no-reply notifications
|
||||
# sender) by setting this explicitly.
|
||||
unsubscribe_mailto=os.environ.get(
|
||||
"EMAIL_UNSUBSCRIBE_MAILTO",
|
||||
os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local"),
|
||||
).strip(),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +109,14 @@ def _signer() -> URLSafeSerializer:
|
||||
|
||||
|
||||
def make_unsubscribe_url(user_id: int, category: str) -> str:
|
||||
"""Build the §15.4 per-category one-click URL.
|
||||
|
||||
`category` is one of `personal-direct`, `structural`,
|
||||
`admin-actionable` (the three per-category flags) or `all`
|
||||
(v0.18.0: the bundle path, which sets `email_opt_out_all = 1`
|
||||
because a bundle covers multiple categories and a per-category
|
||||
opt-out wouldn't honor the user's intent).
|
||||
"""
|
||||
cfg = EmailConfig.from_env()
|
||||
token = _signer().dumps({"u": user_id, "c": category})
|
||||
qs = urlencode({"t": token})
|
||||
@@ -250,7 +269,21 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
|
||||
return
|
||||
subject = _subject(payload)
|
||||
body = _body(payload, user["id"], category, cfg)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
# v0.18.0: notification mail is bulk-adjacent (a watcher can
|
||||
# accumulate dozens of structural events on a busy RFC), so it
|
||||
# carries the full one-click unsubscribe — Gmail and Yahoo
|
||||
# require this for senders at OHM's volume tier per RFC 8058.
|
||||
unsubscribe_url = make_unsubscribe_url(user["id"], category)
|
||||
sent = _deliver(
|
||||
cfg,
|
||||
user["email"],
|
||||
subject,
|
||||
body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="notification",
|
||||
notification_id=notif_id,
|
||||
)
|
||||
if not sent:
|
||||
return
|
||||
db.conn().execute(
|
||||
@@ -305,23 +338,65 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
||||
return cfg.app_url
|
||||
|
||||
|
||||
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
|
||||
def _deliver(
|
||||
cfg: EmailConfig,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
unsubscribe_mailto: str | None = None,
|
||||
unsubscribe_url: str | None = None,
|
||||
kind: str = "notification",
|
||||
notification_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Build the envelope via the shared `build_envelope` helper and
|
||||
hand it to SMTP.
|
||||
|
||||
The `_SENT` buffer carries the helper's `EmailMessage` under
|
||||
`message` plus the legacy `to`/`from`/`subject`/`body` keys for
|
||||
backward-compatibility with tests that read those directly.
|
||||
Newer tests can assert on the header surface by inspecting
|
||||
`envelope["message"]`.
|
||||
|
||||
v0.18.0 Slice 4: also writes one row to `outbound_emails`
|
||||
capturing the attempt. status='sent' on success, 'failed' on
|
||||
SMTP exception, 'deferred' on the dev-fallback path (no
|
||||
SMTP_HOST configured — the send didn't happen, but the row
|
||||
records the attempt so the admin endpoint can answer "did the
|
||||
framework try?").
|
||||
"""
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
unsubscribe_mailto=unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"message": msg,
|
||||
"kind": kind,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.smtp_host:
|
||||
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="deferred",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
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:
|
||||
@@ -331,12 +406,78 @@ def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="sent",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.exception("email send failed: to=%s subject=%s", to_address, subject)
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def record_outbound(
|
||||
*,
|
||||
to_address: str,
|
||||
from_address: str,
|
||||
subject: str,
|
||||
kind: str,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
notification_id: int | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> int | None:
|
||||
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
|
||||
|
||||
Returns the inserted row's id, or `None` if the DB connection
|
||||
isn't initialized (which happens in unit tests that don't boot
|
||||
the full app — the write is best-effort and never raises).
|
||||
"""
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO outbound_emails
|
||||
(to_address, from_address, subject, kind, sent_at, status,
|
||||
error, notification_id, message_id)
|
||||
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
to_address,
|
||||
from_address,
|
||||
subject,
|
||||
kind,
|
||||
status,
|
||||
error,
|
||||
notification_id,
|
||||
message_id,
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except RuntimeError:
|
||||
# db.conn() raises RuntimeError if init() hasn't been called.
|
||||
# Pure-helper unit tests for build_envelope hit this path; the
|
||||
# audit row is best-effort and not part of the contract.
|
||||
return None
|
||||
except Exception:
|
||||
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quiet-hours release pass — called from the digest job
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -440,13 +581,27 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
|
||||
for r, _cat, extras in group_rows:
|
||||
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
|
||||
sections.append(f" · {summary}")
|
||||
# v0.18.0: the bundle covers multiple categories, so a
|
||||
# per-category opt-out can't honor the user's intent. The
|
||||
# `all` category lands at the §15.4 endpoint and sets
|
||||
# `email_opt_out_all = 1`.
|
||||
unsubscribe_url = make_unsubscribe_url(user["id"], "all")
|
||||
body = (
|
||||
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
|
||||
+ "\n".join(sections)
|
||||
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
|
||||
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
|
||||
+ f"Unsubscribe from all email: {unsubscribe_url}\n"
|
||||
)
|
||||
sent = _deliver(
|
||||
cfg,
|
||||
user["email"],
|
||||
subject,
|
||||
body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="bundle",
|
||||
)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
if not sent:
|
||||
return 0
|
||||
ids = [r["id"] for r, _, _ in emailable]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder.
|
||||
|
||||
Every outbound mail in rfc-app today (OTC, admin-invite, watcher
|
||||
notification, "while you were away" bundle, per-RFC invite) constructs
|
||||
its own `email.message.EmailMessage` ad-hoc. The four sites diverged
|
||||
just enough to be a deliverability hazard: missing `Date`, missing
|
||||
`Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the
|
||||
bulk-adjacent paths, no `multipart/alternative` body.
|
||||
|
||||
This module is the one place an `EmailMessage` is constructed. Every
|
||||
send path imports `build_envelope` and calls it; the headers that
|
||||
matter for inbox placement (Date, Message-ID, Auto-Submitted) land
|
||||
uniformly, and the per-kind variations (unsubscribe semantics,
|
||||
HTML alternative) are explicit arguments rather than buried in
|
||||
each call site.
|
||||
|
||||
Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`,
|
||||
the per-kind unsubscribe matrix is:
|
||||
|
||||
* OTC: no `List-Unsubscribe` (the recipient explicitly requested
|
||||
the code; advertising an unsubscribe header would imply OHM has
|
||||
them on a list, which it doesn't).
|
||||
* Admin invite / per-RFC invite: `mailto:` form only (the
|
||||
recipient isn't a user yet, so there's no per-user opt-out row
|
||||
to flip; the operator handles ad-hoc opt-outs manually).
|
||||
* Watcher notification / bundle: full `mailto:` + signed-URL
|
||||
`List-Unsubscribe` plus `List-Unsubscribe-Post:
|
||||
List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo
|
||||
enforce this for bulk-adjacent senders).
|
||||
|
||||
The `is_transactional` flag governs `Auto-Submitted: auto-generated`,
|
||||
which prevents auto-responder loops on every kind of mail we send.
|
||||
All five mail kinds today are transactional in the SMTP sense (no
|
||||
human is at the From mailbox watching for replies), so the default
|
||||
is True; the argument is exposed for future symmetry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
||||
|
||||
def build_envelope(
|
||||
*,
|
||||
to_address: str,
|
||||
from_address: str,
|
||||
from_name: str,
|
||||
subject: str,
|
||||
body_plain: str,
|
||||
body_html: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
unsubscribe_mailto: str | None = None,
|
||||
unsubscribe_url: str | None = None,
|
||||
is_transactional: bool = True,
|
||||
msgid_domain: str | None = None,
|
||||
) -> EmailMessage:
|
||||
"""Compose an `EmailMessage` with hardened headers.
|
||||
|
||||
`to_address` / `from_address` are bare RFC 5322 addresses;
|
||||
`from_name` is the display label that goes through `formataddr`
|
||||
so spaces / commas in the display string are encoded correctly.
|
||||
|
||||
`body_plain` is mandatory. `body_html`, if supplied, lands as the
|
||||
second part of a `multipart/alternative` body — mail clients
|
||||
that prefer HTML render it; clients that don't fall back to the
|
||||
plain part. The text/plain part comes first per RFC 2046, so a
|
||||
plain-text client that picks the first body gets the readable
|
||||
text.
|
||||
|
||||
`reply_to`, when set, lets a send path point replies at a
|
||||
different mailbox than the From line (e.g., a watcher
|
||||
notification with From=notifications@... but Reply-To=
|
||||
ohm@... so a confused recipient who hits Reply lands at a
|
||||
monitored mailbox).
|
||||
|
||||
`unsubscribe_mailto` / `unsubscribe_url` populate
|
||||
`List-Unsubscribe`. If `unsubscribe_url` is set, the helper also
|
||||
emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per
|
||||
RFC 8058 — Gmail and Yahoo POST that payload on the user's
|
||||
one-click action. (Send paths that wire `unsubscribe_url`
|
||||
therefore MUST also expose a matching POST endpoint that accepts
|
||||
the same token; see `api_notifications.py:email_unsubscribe`.)
|
||||
|
||||
`msgid_domain` defaults to the @-domain of `from_address` so
|
||||
Message-IDs are aligned with the sending domain by default. A
|
||||
deployment that wants the Message-ID domain to track a different
|
||||
surface (e.g., a tracking-domain that's separate from the From
|
||||
domain) can override.
|
||||
|
||||
`Date` is RFC 5322 formatted via `email.utils.formatdate`; the
|
||||
`localtime=True` setting picks the running process's local
|
||||
timezone, which is what every popular MUA does too. (A
|
||||
deployment running in UTC stamps UTC; that's correct, not a
|
||||
bug.)
|
||||
"""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((from_name, from_address))
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
# If the caller didn't pin a Message-ID domain, derive it from the
|
||||
# From address. `make_msgid` accepts None and falls back to the
|
||||
# local hostname, which is the wrong shape for a deliverable
|
||||
# message (the hostname might be `gke-pool-xxx`); a deployment
|
||||
# without a configured From would surface that as a build-time
|
||||
# config error elsewhere, so the fallback here is just defensive.
|
||||
if msgid_domain is None:
|
||||
if "@" in from_address:
|
||||
msgid_domain = from_address.split("@", 1)[1]
|
||||
else:
|
||||
msgid_domain = "localhost"
|
||||
msg["Message-ID"] = make_msgid(domain=msgid_domain)
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
if is_transactional:
|
||||
# RFC 3834: prevents auto-responders (vacation replies, etc.)
|
||||
# from triggering on this message. Every kind of mail rfc-app
|
||||
# sends today is transactional in this sense.
|
||||
msg["Auto-Submitted"] = "auto-generated"
|
||||
if unsubscribe_mailto or unsubscribe_url:
|
||||
parts: list[str] = []
|
||||
if unsubscribe_mailto:
|
||||
parts.append(f"<mailto:{unsubscribe_mailto}>")
|
||||
if unsubscribe_url:
|
||||
parts.append(f"<{unsubscribe_url}>")
|
||||
msg["List-Unsubscribe"] = ", ".join(parts)
|
||||
if unsubscribe_url:
|
||||
# RFC 8058 one-click. Gmail and Yahoo POST the payload
|
||||
# `List-Unsubscribe=One-Click` to the URL on the user's
|
||||
# one-click action; the matching POST endpoint must be
|
||||
# idempotent and not require auth. See
|
||||
# `api_notifications.py` for the receiver.
|
||||
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
if body_html:
|
||||
# multipart/alternative: text/plain first, text/html second.
|
||||
# `set_content` sets the first part (and the message's main
|
||||
# body); `add_alternative` adds the second part and
|
||||
# restructures the message as multipart/alternative.
|
||||
msg.set_content(body_plain)
|
||||
msg.add_alternative(body_html, subtype="html")
|
||||
else:
|
||||
msg.set_content(body_plain)
|
||||
return msg
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
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)
|
||||
# v0.18.0: invite mail carries a `List-Unsubscribe: <mailto:…>`
|
||||
# only (no signed URL) — the invitee isn't a user yet, so there
|
||||
# is no per-user opt-out row to flip. The operator handles
|
||||
# ad-hoc opt-outs from the mailto: target. Per the proposal's
|
||||
# "Tradeoff discussion": the invite was unsolicited from the
|
||||
# recipient's perspective, so the courtesy header is right;
|
||||
# but it can't be a one-click URL because the row doesn't
|
||||
# exist yet.
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "invite",
|
||||
"message": msg,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.enabled:
|
||||
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
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)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
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()
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="sent", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.exception("invite email send failed: to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||
)
|
||||
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"
|
||||
)
|
||||
@@ -23,10 +23,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,31 +44,48 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = f"Your sign-in code for {cfg.from_name}"
|
||||
body = _body(code, cfg)
|
||||
# v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient
|
||||
# explicitly requested the code; advertising an unsubscribe
|
||||
# header would imply OHM has them on a list, which it doesn't.
|
||||
# See the proposal's "Tradeoff discussion" for the binding
|
||||
# rationale.
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "otc",
|
||||
"message": msg,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.enabled:
|
||||
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback: surface the code at INFO so the operator can
|
||||
# complete a sign-in flow without an SMTP relay. In production
|
||||
# SMTP_HOST is always set per OHM's overlay.
|
||||
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||
)
|
||||
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:
|
||||
@@ -78,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="sent", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.exception("otc email send failed: to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
"""§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,6 +24,7 @@ from . import (
|
||||
digest,
|
||||
email_otc,
|
||||
hygiene,
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
@@ -72,6 +73,25 @@ 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()
|
||||
@@ -382,6 +402,95 @@ 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).
|
||||
#
|
||||
|
||||
+33
-1
@@ -12,6 +12,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
x_gitea_signature: str = Header(default=""),
|
||||
):
|
||||
body = await request.body()
|
||||
if config.webhook_secret:
|
||||
# v0.18.0: defense in depth. config.py refuses to start
|
||||
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
||||
# is set; this branch catches the dev-bypass case (the only
|
||||
# path where `config.webhook_secret` can be empty) and surfaces
|
||||
# it loudly to the client. A POST that lands here with an
|
||||
# empty secret on a production deployment indicates a
|
||||
# mis-configuration (somebody flipped the bypass in prod),
|
||||
# and the loud 500 is the proposal's whole point.
|
||||
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
|
||||
if not config.webhook_secret:
|
||||
if not insecure:
|
||||
log.error(
|
||||
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
|
||||
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
|
||||
log.warning(
|
||||
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
|
||||
"signature verification is DISABLED. Production deployments MUST NOT set this."
|
||||
)
|
||||
else:
|
||||
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
@@ -68,6 +89,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
slug = _slug_for_repo(repo_full)
|
||||
if slug:
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
else:
|
||||
# v0.18.0: the proposal's "unknown-repo logging"
|
||||
# gesture — a hook on a fork or a stale repo binding
|
||||
# used to silently 200-OK here, hiding the
|
||||
# misconfiguration. Now the operator sees it in
|
||||
# the log.
|
||||
log.info(
|
||||
"webhook received for unknown repo: repo_full=%s event=%s "
|
||||
"(no cached_rfcs row matched; hook may be on a fork or stale)",
|
||||
repo_full, event,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("webhook refresh failed")
|
||||
raise HTTPException(status_code=500, detail="Refresh failed")
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
-- §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);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- v0.18.0 Slice 4: outbound_emails audit table.
|
||||
--
|
||||
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
|
||||
-- helper writes a row to this table before returning, regardless
|
||||
-- of outcome. status='sent' on success, 'failed' on exception,
|
||||
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
|
||||
--
|
||||
-- The table is queried by `GET /api/admin/outbound-emails` to
|
||||
-- answer "did this person ever get their invite?" without having
|
||||
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
|
||||
-- hook (which looks up message_id when a POST lands at
|
||||
-- /api/webhooks/email-bounce and marks the matching row
|
||||
-- status='bounced').
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbound_emails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
to_address TEXT NOT NULL,
|
||||
from_address TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
|
||||
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
|
||||
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
|
||||
error TEXT, -- exception class + message if status='failed'
|
||||
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
|
||||
message_id TEXT -- the Message-ID header value, for bounce correlation
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
|
||||
@@ -0,0 +1,728 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0: invite-envelope header shape — Slice 2
|
||||
#
|
||||
# Invite mail goes through `build_envelope` and MUST land Date,
|
||||
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
|
||||
# (no URL — the invitee isn't a user yet, so no per-user opt-out
|
||||
# row exists). The mailto: target is the operator's `EMAIL_FROM`
|
||||
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
|
||||
provision_user_row(user_id=400, login="adminH", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=400, gitea_login="adminH",
|
||||
display_name="Admin H", role="admin",
|
||||
email="adminh@test",
|
||||
)
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": to,
|
||||
"first_name": "Header",
|
||||
"last_name": "Test",
|
||||
"role": "contributor",
|
||||
"custom_message": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return _outbound_invite_envelopes(to)[-1]
|
||||
|
||||
|
||||
def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
|
||||
"""The invitee isn't a user yet — no per-user opt-out URL is
|
||||
available. The `List-Unsubscribe` MUST be a mailto: form, and
|
||||
the `List-Unsubscribe-Post` header MUST be absent (the
|
||||
one-click semantic requires a URL the MUA can POST to)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert lu is not None and lu.startswith("<mailto:")
|
||||
# No URL part — invite is mailto-only.
|
||||
assert "https://" not in lu and "http://" not in lu
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
|
||||
"""When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
|
||||
`List-Unsubscribe` honors it (lets a deployment route opt-outs
|
||||
to a humans-monitored mailbox distinct from the no-reply
|
||||
sender)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
|
||||
@@ -231,13 +231,17 @@ def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gi
|
||||
|
||||
# With the right header, the call passes the guard. (No matching
|
||||
# user exists, so we get {matched: False} — that's the v1 contract.)
|
||||
# v0.18.0 Slice 5: the response now includes `correlated_id`
|
||||
# (the outbound_emails row id that matched the bounce's
|
||||
# `message_id`, if one was supplied). The body didn't pass a
|
||||
# message_id, so correlated_id is None.
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "stranger@example.com", "kind": "hard"},
|
||||
headers={"X-Webhook-Secret": "shhh"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "matched": False}
|
||||
assert r.json() == {"ok": True, "matched": False, "correlated_id": None}
|
||||
|
||||
|
||||
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
|
||||
|
||||
These tests don't spin up the FastAPI app or touch the DB — they
|
||||
exercise the helper directly. The integration tests in
|
||||
test_otc_vertical / test_admin_create_user_invite_vertical /
|
||||
test_notifications_vertical exercise the helper's *use* via the
|
||||
shared `_SENT` buffer (the send path appends the envelope dict
|
||||
before invoking the helper).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from app.email_envelope import build_envelope
|
||||
|
||||
|
||||
def _base_kwargs(**overrides):
|
||||
base = dict(
|
||||
to_address="recipient@example.com",
|
||||
from_address="notifications@ohm.wiggleverse.org",
|
||||
from_name="OHM",
|
||||
subject="A test subject",
|
||||
body_plain="Hello, world.\n",
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Always-present headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_from_to_subject():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["To"] == "recipient@example.com"
|
||||
assert msg["Subject"] == "A test subject"
|
||||
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
|
||||
assert "OHM" in msg["From"]
|
||||
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
|
||||
|
||||
|
||||
def test_envelope_sets_date_header_parseable():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
raw = msg["Date"]
|
||||
assert raw, "Date header must be set"
|
||||
# parsedate_to_datetime raises ValueError on malformed input.
|
||||
dt = parsedate_to_datetime(raw)
|
||||
assert dt is not None
|
||||
|
||||
|
||||
def test_envelope_sets_message_id_with_from_domain_by_default():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
mid = msg["Message-ID"]
|
||||
assert mid, "Message-ID must be set"
|
||||
# Shape per RFC 5322 / make_msgid: <random@domain>
|
||||
assert mid.startswith("<") and mid.endswith(">")
|
||||
assert "@ohm.wiggleverse.org>" in mid
|
||||
|
||||
|
||||
def test_envelope_message_id_domain_override():
|
||||
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
|
||||
assert "@example.test>" in msg["Message-ID"]
|
||||
|
||||
|
||||
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
|
||||
# Defensive: a malformed from_address shouldn't crash the helper.
|
||||
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
|
||||
assert "@localhost>" in msg["Message-ID"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-Submitted (RFC 3834)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_auto_submitted_for_transactional_default():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_envelope_omits_auto_submitted_when_transactional_is_false():
|
||||
msg = build_envelope(**_base_kwargs(is_transactional=False))
|
||||
assert msg["Auto-Submitted"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply-To
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_reply_to_when_provided():
|
||||
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
|
||||
assert msg["Reply-To"] == "ohm@wiggleverse.org"
|
||||
|
||||
|
||||
def test_envelope_omits_reply_to_when_absent():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["Reply-To"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_no_list_unsubscribe_when_neither_given():
|
||||
"""OTC mail: the recipient explicitly requested the code; no
|
||||
unsubscribe semantics. The header MUST be absent (presence would
|
||||
imply OHM has the recipient on a list, which it doesn't)."""
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["List-Unsubscribe"] is None
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_envelope_mailto_only_list_unsubscribe():
|
||||
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
|
||||
The recipient isn't a user yet, so there's no per-user opt-out
|
||||
URL to flip; the operator handles ad-hoc opt-outs manually."""
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
||||
))
|
||||
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
|
||||
# NO List-Unsubscribe-Post when only a mailto is present — the
|
||||
# one-click semantic requires a URL the MUA can POST to.
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_envelope_full_one_click_list_unsubscribe():
|
||||
"""Watcher notification / bundle: `mailto:` + signed-URL +
|
||||
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
|
||||
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
||||
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
||||
))
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
|
||||
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
def test_envelope_url_only_list_unsubscribe_still_sets_post():
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
||||
))
|
||||
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body shape — plain-only vs multipart/alternative
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_plain_only_body_is_text_plain():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
# No HTML alternative -> single-part text/plain.
|
||||
assert msg.get_content_type() == "text/plain"
|
||||
assert msg.get_content().strip() == "Hello, world."
|
||||
|
||||
|
||||
def test_envelope_with_html_is_multipart_alternative():
|
||||
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
# Two parts: text/plain first (so plain-text clients picking the
|
||||
# first part get the readable text), text/html second.
|
||||
parts = list(msg.iter_parts())
|
||||
assert len(parts) == 2
|
||||
assert parts[0].get_content_type() == "text/plain"
|
||||
assert parts[1].get_content_type() == "text/html"
|
||||
assert "Hello, world." in parts[0].get_content()
|
||||
assert "<b>world</b>" in parts[1].get_content()
|
||||
@@ -612,3 +612,127 @@ def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
|
||||
# the user put them.
|
||||
assert row["set_by"] == "explicit"
|
||||
assert row["state"] == "following"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0 — envelope headers + RFC 8058 one-click POST endpoint
|
||||
#
|
||||
# Watcher notifications are bulk-adjacent (a busy RFC can produce
|
||||
# dozens of structural events); per the proposal, they MUST carry
|
||||
# `Date`, `Message-ID`, `Auto-Submitted`, full `List-Unsubscribe`
|
||||
# (mailto + signed URL), AND `List-Unsubscribe-Post:
|
||||
# List-Unsubscribe=One-Click` per RFC 8058. Gmail and Yahoo
|
||||
# enforce this for senders at OHM's volume tier.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification_envelope_carries_full_one_click_headers(app_with_fake_gitea):
|
||||
"""A `proposal_merged` event lands a watcher notification email
|
||||
with the full one-click unsubscribe shape."""
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
|
||||
assert r.status_code == 200
|
||||
email_mod.reset_sent_envelopes()
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
assert merge_r.status_code == 200, merge_r.text
|
||||
|
||||
envelopes = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
|
||||
assert envelopes, "watcher notification did not fire"
|
||||
msg = envelopes[-1]["message"]
|
||||
# Always-present headers from the helper.
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
# Full one-click unsubscribe.
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert lu is not None
|
||||
assert "<mailto:" in lu
|
||||
# URL part carries the signed token per make_unsubscribe_url.
|
||||
assert "/api/email/unsubscribe?t=" in lu
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_one_click_flips_category_off(app_with_fake_gitea):
|
||||
"""RFC 8058: Gmail/Yahoo POST `List-Unsubscribe=One-Click` to the
|
||||
URL in the List-Unsubscribe header. The endpoint MUST accept POST
|
||||
+ the same token shape as the GET handler + return 200 + flip the
|
||||
flag."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
|
||||
|
||||
r = client.post(
|
||||
f"/api/email/unsubscribe?t={token}",
|
||||
data={"List-Unsubscribe": "One-Click"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["category"] == "personal-direct"
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT email_personal_direct FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_personal_direct"] == 0
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_all_sets_global_opt_out(app_with_fake_gitea):
|
||||
"""The v0.18.0 `all` synthetic category (used by the bundle +
|
||||
digest paths) MUST set `email_opt_out_all = 1`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
|
||||
r = client.post(f"/api/email/unsubscribe?t={token}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "category": "all"}
|
||||
row = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_email_unsubscribe_get_all_sets_global_opt_out(app_with_fake_gitea):
|
||||
"""GET handler also accepts the `all` category and lands the
|
||||
global opt-out (so an MUA that doesn't honor RFC 8058 POST and
|
||||
just opens the URL in a browser still works)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
|
||||
r = client.get(f"/api/email/unsubscribe?t={token}")
|
||||
assert r.status_code == 200
|
||||
assert "Unsubscribed" in r.text
|
||||
row = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_refuses_invalid_token(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/email/unsubscribe?t=not-a-valid-token")
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -347,3 +347,53 @@ def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monke
|
||||
# The new code still works.
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0: envelope headers — Slice 2
|
||||
#
|
||||
# OTC mail goes through `build_envelope` and MUST land Date,
|
||||
# Message-ID, and Auto-Submitted but MUST NOT carry a
|
||||
# List-Unsubscribe header (the recipient explicitly requested the
|
||||
# code; advertising a list semantic would be wrong).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _last_otc_envelope():
|
||||
from app import email as email_mod
|
||||
otc = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
|
||||
assert otc, "no OTC envelope in the buffer"
|
||||
return otc[-1]
|
||||
|
||||
|
||||
def test_otc_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "headers@example.com"})
|
||||
msg = _last_otc_envelope()["message"]
|
||||
# Date is RFC 5322 parseable.
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
# Message-ID is bracketed and carries the From-address @-domain.
|
||||
mid = msg["Message-ID"]
|
||||
assert mid.startswith("<") and mid.endswith(">")
|
||||
# Auto-Submitted prevents auto-responder loops.
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_otc_envelope_has_no_list_unsubscribe(app_with_fake_gitea):
|
||||
"""The recipient explicitly typed their email and asked for a
|
||||
code; the framework MUST NOT advertise a list semantic on this
|
||||
mail. Per the v0.18.0 proposal's tradeoff discussion."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "headers@example.com"})
|
||||
msg = _last_otc_envelope()["message"]
|
||||
assert msg["List-Unsubscribe"] is None
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""End-to-end integration tests for the v0.18.0 Slice 4
|
||||
outbound_emails audit table + admin endpoint.
|
||||
|
||||
The release adds:
|
||||
* `backend/migrations/020_outbound_emails.sql` — the audit table.
|
||||
* `record_outbound()` in `email.py` — the write helper every send
|
||||
path calls before returning, capturing status='sent' / 'failed'
|
||||
/ 'deferred' (the dev-fallback path when SMTP_HOST is unset).
|
||||
* `GET /api/admin/outbound-emails` — admin-only listing, filterable
|
||||
by kind / status / to_address.
|
||||
|
||||
These tests prove:
|
||||
* Sending OTC / invite / notification mail writes one row per send
|
||||
(status='deferred' under tests since SMTP_HOST is unset).
|
||||
* The Message-ID on the row matches the envelope's Message-ID
|
||||
header (the seam Slice 5 uses for bounce correlation).
|
||||
* `kind` is populated per send path.
|
||||
* `GET /api/admin/outbound-emails` lists rows newest-first,
|
||||
accepts filters, refuses non-admins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write-on-send wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_send_writes_outbound_row_with_message_id(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
email_mod.reset_sent_envelopes()
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@ex.co"})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Audit row landed.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, to_address, kind, status, message_id, error "
|
||||
"FROM outbound_emails WHERE to_address = 'newcomer@ex.co'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["kind"] == "otc"
|
||||
# No SMTP_HOST in tests -> 'deferred', not 'sent'.
|
||||
assert row["status"] == "deferred"
|
||||
assert row["error"] is None
|
||||
# Message-ID matches the envelope's header (the seam Slice 5 uses).
|
||||
envelopes = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
|
||||
assert envelopes
|
||||
envelope_mid = envelopes[-1]["message"]["Message-ID"]
|
||||
assert row["message_id"] == envelope_mid
|
||||
|
||||
|
||||
def test_invite_send_writes_outbound_row(app_with_fake_gitea):
|
||||
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=500, login="adminQ", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=500, gitea_login="adminQ",
|
||||
display_name="Admin Q", role="admin",
|
||||
email="adminq@test",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "invitee@ex.co",
|
||||
"first_name": "Inv", "last_name": "Itee",
|
||||
"role": "contributor", "custom_message": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT kind, status, message_id FROM outbound_emails "
|
||||
"WHERE to_address = 'invitee@ex.co'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "invite"
|
||||
assert rows[0]["status"] == "deferred"
|
||||
assert rows[0]["message_id"] is not None
|
||||
|
||||
|
||||
def test_notification_send_writes_outbound_row_with_notification_id(app_with_fake_gitea):
|
||||
"""Watcher notifications carry a `notification_id` FK so the
|
||||
admin can join through to the notifications table to see what
|
||||
triggered the send."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
from test_notifications_vertical import PITCH
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor", email="alice@test",
|
||||
)
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||
})
|
||||
email_mod.reset_sent_envelopes()
|
||||
# Wipe pre-merge audit rows so the assertion below is unambiguous.
|
||||
db.conn().execute("DELETE FROM outbound_emails")
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test",
|
||||
)
|
||||
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
assert merge_r.status_code == 200, merge_r.text
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT kind, status, notification_id, message_id "
|
||||
"FROM outbound_emails WHERE to_address = 'alice@test'"
|
||||
).fetchall()
|
||||
assert rows, "no outbound_emails row for alice@test"
|
||||
# At least one notification kind, with a populated FK.
|
||||
notif_rows = [r for r in rows if r["kind"] == "notification"]
|
||||
assert notif_rows
|
||||
for nr in notif_rows:
|
||||
assert nr["status"] == "deferred"
|
||||
assert nr["notification_id"] is not None
|
||||
assert nr["message_id"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_outbound_emails_lists_rows(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Generate a few rows.
|
||||
client.post("/auth/otc/request", json={"email": "one@ex.co"})
|
||||
|
||||
provision_user_row(user_id=600, login="adminR", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=600, gitea_login="adminR",
|
||||
display_name="Admin R", role="admin", email="adminr@test",
|
||||
)
|
||||
client.post("/api/admin/users", json={
|
||||
"email": "two@ex.co", "first_name": "T", "last_name": "Wo",
|
||||
"role": "contributor", "custom_message": "",
|
||||
})
|
||||
|
||||
r = client.get("/api/admin/outbound-emails")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
kinds = {it["kind"] for it in items}
|
||||
assert "otc" in kinds
|
||||
assert "invite" in kinds
|
||||
# Newest-first.
|
||||
ids = [it["id"] for it in items]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_filters_by_kind(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.post("/auth/otc/request", json={"email": "filter1@ex.co"})
|
||||
|
||||
provision_user_row(user_id=601, login="adminS", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=601, gitea_login="adminS",
|
||||
display_name="Admin S", role="admin", email="admins@test",
|
||||
)
|
||||
client.post("/api/admin/users", json={
|
||||
"email": "filter2@ex.co", "first_name": "F", "last_name": "Two",
|
||||
"role": "contributor", "custom_message": "",
|
||||
})
|
||||
|
||||
r = client.get("/api/admin/outbound-emails?kind=otc")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["kind"] == "otc" for it in items)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_filters_by_to_address(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.post("/auth/otc/request", json={"email": "TARGET@ex.co"})
|
||||
client.post("/auth/otc/request", json={"email": "other@ex.co"})
|
||||
|
||||
provision_user_row(user_id=602, login="adminT", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=602, gitea_login="adminT",
|
||||
display_name="Admin T", role="admin", email="admint@test",
|
||||
)
|
||||
|
||||
# to_address filter is case-insensitive.
|
||||
r = client.get("/api/admin/outbound-emails?to_address=target@ex.co")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["to_address"].lower() == "target@ex.co" for it in items)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_refuses_non_admin(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=700, login="contribU", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=700, gitea_login="contribU",
|
||||
display_name="Contrib U", role="contributor",
|
||||
)
|
||||
r = client.get("/api/admin/outbound-emails")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0 Slice 5: bounce correlation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bounce_with_message_id_marks_outbound_row_bounced(app_with_fake_gitea):
|
||||
"""When the bounce body includes the original `message_id`, the
|
||||
framework looks it up in outbound_emails and stamps
|
||||
status='bounced' on the matching row. The hard-bounce ->
|
||||
global-opt-out logic still fires."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=800, login="bouncey", role="contributor")
|
||||
db.conn().execute("UPDATE users SET email = 'bouncey@ex.co' WHERE id = 800")
|
||||
|
||||
# Send something to bouncey to land an outbound_emails row.
|
||||
email_mod.reset_sent_envelopes()
|
||||
client.post("/auth/otc/request", json={"email": "bouncey@ex.co"})
|
||||
row = db.conn().execute(
|
||||
"SELECT id, message_id, status FROM outbound_emails "
|
||||
"WHERE to_address = 'bouncey@ex.co'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
original_id = row["id"]
|
||||
message_id = row["message_id"]
|
||||
assert row["status"] == "deferred" # pre-bounce baseline
|
||||
|
||||
# Bounce comes in carrying that message_id.
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={
|
||||
"email": "bouncey@ex.co",
|
||||
"kind": "hard",
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True
|
||||
assert body["correlated_id"] == original_id
|
||||
|
||||
# Audit row stamped.
|
||||
post = db.conn().execute(
|
||||
"SELECT status, error FROM outbound_emails WHERE id = ?",
|
||||
(original_id,),
|
||||
).fetchone()
|
||||
assert post["status"] == "bounced"
|
||||
assert "bounce (hard)" in (post["error"] or "")
|
||||
|
||||
# Hard-bounce global opt-out still fires.
|
||||
urow = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 800"
|
||||
).fetchone()
|
||||
assert urow["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_bounce_with_unknown_message_id_does_not_crash(app_with_fake_gitea):
|
||||
"""A message_id the framework doesn't recognize logs but does
|
||||
NOT 5xx — bounce providers replay old bounces, and the
|
||||
framework can't refuse just because the row was pruned."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={
|
||||
"email": "nobody@ex.co",
|
||||
"kind": "hard",
|
||||
"message_id": "<not-in-our-db@ex.co>",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["correlated_id"] is None
|
||||
|
||||
|
||||
def test_bounce_without_message_id_still_flips_opt_out(app_with_fake_gitea):
|
||||
"""Backward compat: providers that don't surface Message-ID
|
||||
still get the legacy v1 behavior — match by email + flip the
|
||||
global opt-out."""
|
||||
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=801, login="legacybounce", role="contributor")
|
||||
db.conn().execute("UPDATE users SET email = 'legacy@ex.co' WHERE id = 801")
|
||||
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "legacy@ex.co", "kind": "hard"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True
|
||||
assert body["correlated_id"] is None
|
||||
|
||||
urow = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 801"
|
||||
).fetchone()
|
||||
assert urow["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_bounced_rows_show_in_admin_endpoint(app_with_fake_gitea):
|
||||
"""The admin endpoint surfaces bounced rows alongside the rest;
|
||||
filtering by `status=bounced` isolates them."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=802, login="adminB", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=802, gitea_login="adminB",
|
||||
display_name="Admin B", role="admin", email="adminb@test",
|
||||
)
|
||||
email_mod.reset_sent_envelopes()
|
||||
client.post("/auth/otc/request", json={"email": "willbounce@ex.co"})
|
||||
row = db.conn().execute(
|
||||
"SELECT message_id FROM outbound_emails WHERE to_address = 'willbounce@ex.co'"
|
||||
).fetchone()
|
||||
client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "willbounce@ex.co", "kind": "hard", "message_id": row["message_id"]},
|
||||
)
|
||||
|
||||
r = client.get("/api/admin/outbound-emails?status=bounced")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["status"] == "bounced" for it in items)
|
||||
assert any(it["to_address"] == "willbounce@ex.co" for it in items)
|
||||
@@ -438,7 +438,11 @@ def tmp_env(monkeypatch):
|
||||
"SECRET_KEY": "test-secret-key-for-cookies",
|
||||
"DATABASE_PATH": str(db_path),
|
||||
"OWNER_GITEA_LOGIN": "ben",
|
||||
"GITEA_WEBHOOK_SECRET": "",
|
||||
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
|
||||
# per the email + webhook hygiene proposal. Tests bind a fake
|
||||
# value so the framework boots; tests that want to exercise
|
||||
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
|
||||
"ENABLED_MODELS": "claude",
|
||||
}
|
||||
for k, v in env.items():
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""End-to-end integration tests for the Gitea webhook receiver
|
||||
(v0.18.0 Slice 3 — webhook tightening per the email + webhook
|
||||
hygiene proposal).
|
||||
|
||||
The release changes the receiver from "verifies the signature only
|
||||
when a secret is configured; silently accepts unsigned POSTs
|
||||
otherwise" to "requires the secret unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
||||
is set as an explicit dev-bypass." The startup-time check lives in
|
||||
`config.load_config()`; the request-time check lives in
|
||||
`webhooks.receive`.
|
||||
|
||||
These tests prove:
|
||||
|
||||
* The framework refuses to start when `GITEA_WEBHOOK_SECRET` is
|
||||
empty and the dev-bypass is not set.
|
||||
* The dev-bypass (`RFC_APP_INSECURE_WEBHOOKS=1`) lets the
|
||||
framework boot with an empty secret AND lets webhook POSTs
|
||||
land without signature verification (a loud-warning log line
|
||||
surfaces, but the request is accepted).
|
||||
* Default path (secret bound): a POST with a valid signature
|
||||
lands; a POST with an invalid signature gets 401; a POST with
|
||||
no signature gets 401.
|
||||
* Unknown-repo POSTs surface in the log (the "stale Gitea hook"
|
||||
case the proposal targets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup-time secret check (config.load_config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_refuses_to_load_with_empty_secret_and_no_bypass(monkeypatch, tmp_path):
|
||||
"""The framework MUST refuse to start when `GITEA_WEBHOOK_SECRET`
|
||||
is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` is set. This is
|
||||
the v0.18.0 startup-loud-failure shape — silent acceptance was
|
||||
the bug."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
with pytest.raises(RuntimeError, match="GITEA_WEBHOOK_SECRET"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path):
|
||||
"""The explicit `RFC_APP_INSECURE_WEBHOOKS=1` opt-in lets the
|
||||
framework boot with an empty webhook secret. This is the
|
||||
local-dev escape hatch."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setenv("RFC_APP_INSECURE_WEBHOOKS", "1")
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config() # MUST NOT raise
|
||||
assert cfg.webhook_secret == ""
|
||||
|
||||
|
||||
def test_config_loads_with_secret_set(monkeypatch, tmp_path):
|
||||
"""Sanity: the happy path (secret bound, bypass not set) loads
|
||||
cleanly."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "my-real-secret")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg.webhook_secret == "my-real-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request-time signature verification (webhooks.receive)
|
||||
#
|
||||
# The default `app_with_fake_gitea` fixture binds
|
||||
# `GITEA_WEBHOOK_SECRET=test-webhook-secret-for-signature-verification`,
|
||||
# so these tests exercise the production path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SECRET = "test-webhook-secret-for-signature-verification"
|
||||
|
||||
|
||||
def _sign(body: bytes) -> str:
|
||||
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def test_webhook_post_with_valid_signature_accepted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
sig = _sign(body)
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_webhook_post_with_invalid_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": "0" * 64, # wrong signature
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_post_with_missing_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown-repo logging (the "stale hook on a fork" surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_webhook_unknown_repo_logs_at_info(app_with_fake_gitea, caplog):
|
||||
"""Per the proposal: a hook on a fork or a stale Gitea binding
|
||||
used to silently 200-OK. v0.18.0 surfaces it as an INFO log."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "someone-else/unrelated"}}).encode()
|
||||
sig = _sign(body)
|
||||
with caplog.at_level(logging.INFO, logger="app.webhooks"):
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 # the handler still 200s; surface is the log line
|
||||
assert any(
|
||||
"unknown repo" in rec.message and "someone-else/unrelated" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected unknown-repo log line; got: {[r.message for r in caplog.records]}"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.16.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
@@ -227,6 +228,10 @@ export default function App() {
|
||||
<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.
|
||||
|
||||
@@ -799,6 +799,53 @@ 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)
|
||||
|
||||
@@ -23,9 +23,16 @@ 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' },
|
||||
@@ -90,6 +97,10 @@ 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)
|
||||
@@ -179,8 +190,28 @@ 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 => (
|
||||
@@ -231,12 +262,26 @@ 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}` : ''}
|
||||
@@ -326,6 +371,173 @@ 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() {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user