// 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//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 (
{ if (e.target === e.currentTarget) onClose() }}>

Invitations — {rfcTitle || slug}

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 write.

setInviteeEmail(e.target.value)} placeholder="someone@example.com" autoFocus required />
{submitError && {submitError}} {submitSuccess && {submitSuccess}}

Existing invitations

{loadError &&
{loadError}
} {invitations === null &&
Loading…
} {invitations !== null && invitations.length === 0 && (
No invitations have been sent yet.
)} {invitations !== null && invitations.length > 0 && ( {invitations.map(inv => ( ))}
Email Role Status Sent
{inv.invitee_email} {inv.role_in_rfc} {inv.status} {inv.status === 'accepted' && inv.accepted_by_display && ( by {inv.accepted_by_display} )} {inv.created_at?.slice(0, 10) || ''} {inv.status === 'pending' && ( )}
)}
) }