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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// InvitationsModal.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The RFC owner's surface for issuing per-RFC invitations and watching
|
||||
// who has accepted. Opens from the RFC view's header strip when the
|
||||
// viewer is the RFC's owner (or a platform admin/owner). Non-owner
|
||||
// viewers never see the trigger.
|
||||
//
|
||||
// The modal shows two stacked sections:
|
||||
//
|
||||
// 1. "Invite someone" — email input + role picker
|
||||
// (contributor | discussant) + Send. The send goes through the
|
||||
// backend's POST /api/rfcs/<slug>/invitations, which both writes
|
||||
// the row and dispatches the email to the invitee. Success
|
||||
// refreshes the list below and clears the input.
|
||||
//
|
||||
// 2. "Existing invitations" — every invitation (pending +
|
||||
// accepted + revoked + expired) on this RFC, with revoke
|
||||
// buttons on the pending ones. The status of each row is the
|
||||
// effective status (the backend recomputes expired-from-pending
|
||||
// at read time so an unattended cron isn't required).
|
||||
//
|
||||
// No custom-message field — that belongs to item #16's platform-
|
||||
// level surface, not here. No bulk-invite — one email at a time
|
||||
// keeps the gesture deliberate.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
createRFCInvitation,
|
||||
listRFCInvitations,
|
||||
revokeRFCInvitation,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'Contributor — can open PRs and join discussion' },
|
||||
{ value: 'discussant', label: 'Discussant — can join discussion only' },
|
||||
]
|
||||
|
||||
export default function InvitationsModal({ slug, rfcTitle, onClose }) {
|
||||
const [invitations, setInvitations] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [inviteeEmail, setInviteeEmail] = useState('')
|
||||
const [roleInRFC, setRoleInRFC] = useState('contributor')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(null)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(null)
|
||||
const [revokingId, setRevokingId] = useState(null)
|
||||
|
||||
async function refresh() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const r = await listRFCInvitations(slug)
|
||||
setInvitations(r.items || [])
|
||||
} catch (e) {
|
||||
setLoadError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [slug])
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault()
|
||||
const email = inviteeEmail.trim()
|
||||
if (!email) return
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(null)
|
||||
try {
|
||||
await createRFCInvitation(slug, { inviteeEmail: email, roleInRFC })
|
||||
// v0.16.0 + #21 Part C — Amplitude wiring. No PII (the email
|
||||
// is the inviter's input, not the invitee's identity in our
|
||||
// analytics; we record the rfc_slug + role_in_rfc so a future
|
||||
// invite→accept correlation has both halves).
|
||||
track(EVENTS.INVITATION_SENT, { rfc_slug: slug, role_in_rfc: roleInRFC })
|
||||
setSubmitSuccess(`Invitation sent to ${email}.`)
|
||||
setInviteeEmail('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to send invitation.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(invitationId) {
|
||||
setRevokingId(invitationId)
|
||||
try {
|
||||
await revokeRFCInvitation(slug, invitationId)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to revoke invitation.')
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Invitations — {rfcTitle || slug}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Invite people by email to contribute PRs against this RFC or to
|
||||
join its discussion. Anyone with the link can read this RFC;
|
||||
this surface controls who can <em>write</em>.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
|
||||
<label htmlFor="invitee-email">Invitee email</label>
|
||||
<input
|
||||
id="invitee-email"
|
||||
type="email"
|
||||
value={inviteeEmail}
|
||||
onChange={e => setInviteeEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
|
||||
<select
|
||||
id="invitee-role"
|
||||
value={roleInRFC}
|
||||
onChange={e => setRoleInRFC(e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send invitation'}
|
||||
</button>
|
||||
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
|
||||
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr style={{ margin: '20px 0' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
|
||||
{loadError && <div className="error-banner">{loadError}</div>}
|
||||
{invitations === null && <div>Loading…</div>}
|
||||
{invitations !== null && invitations.length === 0 && (
|
||||
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
|
||||
)}
|
||||
{invitations !== null && invitations.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
|
||||
<th style={{ padding: 4 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map(inv => (
|
||||
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
|
||||
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
|
||||
<td style={{ padding: 4 }}>
|
||||
<span className={`invitation-status status-${inv.status}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
{inv.status === 'accepted' && inv.accepted_by_display && (
|
||||
<span style={{ color: '#666', marginLeft: 6 }}>
|
||||
by {inv.accepted_by_display}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 4, color: '#666' }}>
|
||||
{inv.created_at?.slice(0, 10) || ''}
|
||||
</td>
|
||||
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleRevoke(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
import InvitationsModal from './InvitationsModal.jsx'
|
||||
import { claimOwnership } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
@@ -148,6 +149,11 @@ export default function RFCView({ viewer }) {
|
||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
||||
const [claimError, setClaimError] = useState(null)
|
||||
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
|
||||
// RFC owners (frontmatter) and platform admin/owner — the backend
|
||||
// gates the underlying endpoints regardless, so a leaked toggle
|
||||
// can't actually leak anything.
|
||||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
@@ -633,6 +639,20 @@ export default function RFCView({ viewer }) {
|
||||
Graduate to RFC repo
|
||||
</button>
|
||||
)}
|
||||
{/* v0.16.0 (item #12): owner-only invitations affordance.
|
||||
Shown when the viewer is named in the RFC's frontmatter
|
||||
`owners` list or holds a platform admin/owner role.
|
||||
Available on both super-drafts and active RFCs. */}
|
||||
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowInvitationsModal(true)}
|
||||
title="Invite collaborators to this RFC"
|
||||
>
|
||||
Invitations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{claimError && (
|
||||
@@ -865,6 +885,14 @@ export default function RFCView({ viewer }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInvitationsModal && (
|
||||
<InvitationsModal
|
||||
slug={slug}
|
||||
rfcTitle={entry?.title}
|
||||
onClose={() => setShowInvitationsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMetadataPane && (
|
||||
<MetadataPaneModal
|
||||
slug={slug}
|
||||
|
||||
Reference in New Issue
Block a user