Release 0.16.0: owner-only invite for per-RFC contribution + discussion (+ #21 Part C Amplitude wiring)
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").
RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.
Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).
Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.
Amplitude wiring (inline, #21 Part C):
- INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
- INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
- identify() BEFORE the accept event with properties: invited_at
(setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
claim_method: 'rfc-invite'
- EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED
No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.
Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// AcceptInvitation.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The /invitations/accept?token=... landing page the invitation email
|
||||
// links to. The page:
|
||||
//
|
||||
// 1. Reads `?token=...` from the URL.
|
||||
// 2. Calls GET /api/invitations/accept?token=... to preview what the
|
||||
// invitation grants (RFC title, role-in-RFC, expiry, whether the
|
||||
// currently-signed-in user's email matches the invitee's).
|
||||
// 3. Renders a confirmation surface — name the RFC, name the role,
|
||||
// and either show "Accept" (when the email matches and the
|
||||
// invitation is still pending) or a refusal message (expired,
|
||||
// revoked, email mismatch).
|
||||
// 4. On accept, POST /api/invitations/accept lands the
|
||||
// rfc_collaborators row and the page redirects to the RFC's view.
|
||||
//
|
||||
// For an anonymous viewer who lands here without signing in, the
|
||||
// preview call 401s and the page tells them to sign in. After
|
||||
// signing in (via the existing OTC/passcode surface at /login) they
|
||||
// can return to the same URL — the token is stable.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { acceptInvitation, previewInvitation } from '../api'
|
||||
import { EVENTS, identify, track } from '../lib/analytics'
|
||||
|
||||
export default function AcceptInvitation({ viewer }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [acceptError, setAcceptError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPreviewError('No invitation token in the URL.')
|
||||
return
|
||||
}
|
||||
if (!viewer) {
|
||||
// Not signed in — the preview endpoint will 401. We surface a
|
||||
// sign-in prompt without making the request.
|
||||
return
|
||||
}
|
||||
previewInvitation(token)
|
||||
.then(setPreview)
|
||||
.catch(err => setPreviewError(err.message || 'Could not load invitation.'))
|
||||
}, [token, viewer])
|
||||
|
||||
async function handleAccept() {
|
||||
setAccepting(true)
|
||||
setAcceptError(null)
|
||||
try {
|
||||
const result = await acceptInvitation(token)
|
||||
// v0.16.0 + #21 Part C — re-identify with per-RFC invite
|
||||
// properties on accept, BEFORE the track event fires, so the
|
||||
// Amplitude user record carries the invite context from the
|
||||
// moment of acceptance. setOnce on invited_at preserves the
|
||||
// first-accepted timestamp if the same user accepts multiple
|
||||
// RFC invitations.
|
||||
if (viewer?.id != null) {
|
||||
identify({
|
||||
user_id: String(viewer.id),
|
||||
properties: {
|
||||
invited_at: ['__setOnce__', new Date().toISOString()],
|
||||
last_invited_to_rfc: result.rfc_slug,
|
||||
last_invite_role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
claim_method: 'rfc-invite',
|
||||
},
|
||||
})
|
||||
}
|
||||
track(EVENTS.INVITATION_ACCEPTED, {
|
||||
rfc_slug: result.rfc_slug,
|
||||
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
})
|
||||
navigate(`/rfc/${result.rfc_slug}`)
|
||||
} catch (err) {
|
||||
setAcceptError(err.message || 'Could not accept invitation.')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation link is malformed</h1>
|
||||
<p>No <code>token</code> parameter was found. Ask the person who
|
||||
invited you to re-send the link.</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Sign in to accept your invitation</h1>
|
||||
<p>
|
||||
You've been invited to collaborate on an RFC. Sign in first so we
|
||||
can attach the membership to your account, then return to this
|
||||
link.
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/login" className="btn-primary">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation unavailable</h1>
|
||||
<p>{previewError}</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return <div className="accept-invitation">Loading invitation…</div>
|
||||
}
|
||||
|
||||
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
|
||||
|
||||
if (status === 'revoked') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation revoked</h1>
|
||||
<p>
|
||||
The owner of <strong>{rfc_title}</strong> revoked this invitation.
|
||||
Ask them to re-issue it if you should still have access.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation expired</h1>
|
||||
<p>
|
||||
This invitation to <strong>{rfc_title}</strong> has expired. Ask
|
||||
the RFC's owner to issue a fresh one.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'accepted') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Already accepted</h1>
|
||||
<p>
|
||||
You've already accepted this invitation. You can{' '}
|
||||
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!email_matches_you) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>This invitation is for a different account</h1>
|
||||
<p>
|
||||
This invitation was sent to <strong>{invitee_email}</strong>. You're
|
||||
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
|
||||
Sign out and sign back in with the invited address to accept.
|
||||
</p>
|
||||
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Join {rfc_title}</h1>
|
||||
<p>
|
||||
You've been invited to <strong>{rfc_title}</strong> as a{' '}
|
||||
<strong>{role_in_rfc}</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#666' }}>
|
||||
{role_in_rfc === 'contributor'
|
||||
? 'Contributors can open PRs against this RFC and join its discussion.'
|
||||
: 'Discussants can post in this RFC\'s discussion.'}
|
||||
</p>
|
||||
{acceptError && <div className="error-banner">{acceptError}</div>}
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user