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:
@@ -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