// 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 (
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.
Claiming…
)} {status === 'ok' && ( <>Welcome{user?.display_name ? `, ${user.display_name}` : ''}! You're signed in.
{needsPasscode ? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…' : 'Redirecting you to the home page…'}
> )} {status === 'failed' && ( <>{error || "This invite link isn't valid."}
If you believe this is a mistake, contact the admin who sent you the invite — they can issue a fresh link.
> )}