§22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)

The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 00:27:49 -07:00
parent c9fd1c535e
commit 93cf506059
6 changed files with 475 additions and 11 deletions
+27 -3
View File
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api'
import { listRFCs, listProposals, getCollection } from '../api'
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
const STATE_CHIPS = [
@@ -26,6 +26,11 @@ const SORT_OPTIONS = [
export default function Catalog({ viewer, onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([])
// §22 S4: the viewer's contribute capability in this collection, driving the
// propose-first empty state (C3.5) and the propose control. `null` until the
// collection's caps load; we fall back to "any authenticated viewer" so the
// common case doesn't flicker, then refine.
const [canContribute, setCanContribute] = useState(null)
const [search, setSearch] = useState('')
const [sort, setSort] = useState('recent')
const [activeChips, setActiveChips] = useState(new Set())
@@ -39,8 +44,16 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
useEffect(() => {
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
setCanContribute(null)
getCollection(pid, cid)
.then(c => setCanContribute(!!c?.viewer?.can_contribute))
.catch(() => setCanContribute(false))
}, [version, pid, cid])
// While caps load, fall back to "any authenticated viewer" so the propose
// affordance on the common (default-collection) case doesn't flash off.
const mayPropose = canContribute === null ? !!viewer : canContribute
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase()
let items = rfcs.filter(r => {
@@ -98,7 +111,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
// C3.5: a contributor sees a propose-first call to action; a
// granted viewer without contribute rights sees a bare empty
// state; an anonymous reader sees the read-only note (the footer
// carries the sign-in prompt).
? (mayPropose
? 'No entries yet. Propose the first entry below.'
: 'No entries in the catalog yet.')
: 'No matches.'}
</div>
) : (
@@ -154,7 +173,12 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
<div className="catalog-footer">
{viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
// §22 S4: the propose control is offered only when the viewer may
// contribute to *this* collection (the scope-role gate); a granted
// viewer with no contribute right in this collection sees nothing.
mayPropose && (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
)
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>