ee4925b6ac
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>
203 lines
8.1 KiB
React
203 lines
8.1 KiB
React
// 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>
|
||
)
|
||
}
|