Release 0.17.0: admin-create user + invite email (+ #21 Part C Amplitude wiring)
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.
From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).
Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.
Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.
Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).
Amplitude wiring (inline, #21 Part C):
- USER_INVITED from CreateUserInviteModal { target_user_id,
initial_role, custom_message_chars }
- INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
initial_role, needs_passcode, trust_device }
- identify() BEFORE the claim event with 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
Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user