Release 0.17.0: admin-create user + invite email (custom message; claim-link claim flow)
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an admin can now create a user record before that person has ever signed in — typing first name, last name, email, role, and an optional custom message — and the framework sends an invite email carrying a single-use claim link. The invitee clicks through to /invites/claim?token=…, the token is consumed, the session is established, and the user is routed to the passcode-set screen on first sign-in. New endpoints: * POST /api/admin/users — admin-only; provisions the users row + user_invite_tokens row + sends the invite email + writes a permission_events row with event_kind='user_invited'. * GET /api/admin/users/invites — admin-only; lists active (not-claimed, not-expired) invites with the issuing admin. * POST /api/invites/claim — anonymous-reachable; validates the token, consumes the row, signs the invitee in (skipping OTC per the roadmap — clicking the email link is itself proof of email control), returns needs_passcode for the frontend's route-onward decision. Schema: migration slot 019 — user_invite_tokens (id, email, role, first/last name, custom_message, bcrypt token_hash, expires_at, created_at, created_by_admin_id, claimed_at, claimed_by_user_id, invited_user_id). Slot 018 reserved for the parallel #12 release (per-RFC invitation) shipping in the same wave; distinct table (rfc_invitations there vs. user_invite_tokens here) so they coexist cleanly. No users-table changes — the brief floated a NULL-column discriminator for "(pending invite)" but the existing users.last_seen_at is NOT NULL with a datetime('now') default, so the discriminator is the active user_invite_tokens row joined on invited_user_id; the admin user-listing carries a pending_invite field populated via that join. Claim route: frontend /invites/claim?token=… (new InviteClaim.jsx). Anonymous-reachable; renders "Claim my account" CTA with an optional v0.11.0-style "trust this device" checkbox, calls the claim endpoint, routes onward. Token shape: opaque DB token (256 bits CSPRNG via secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque chosen because admin revocation is then a single SQL UPDATE — JWT would be stateless but harder to invalidate. Open-question decisions: immediate-send (no admin-review-then- send queue; future enhancement), no bulk-invite (deferred to follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant (INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var configurability is a §19.2 candidate), OTC skipped on first sign-in (the token in the email is itself proof of email control; subsequent sign-ins go through OTC / passcode unchanged). Refusals on POST /api/admin/users: * 422 self-invite (use the role-change channel for self-edits) * 409 duplicate email (use the existing grant/role gestures) * 422 owner-grant by non-owner admin (§6.1 owner-zero is the only owner bootstrap path) * 422 pydantic — malformed email / unknown role / custom_message > 500 chars * 403 non-admin caller / 401 anonymous 15 new backend tests in test_admin_create_user_invite_vertical.py (happy path, all four refusals, claim with valid / expired / already-claimed / unknown token, pending-invite badge before and after claim, listing admin-only). 234 total backend tests pass; frontend build succeeds. 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