§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
+35 -1
View File
@@ -204,11 +204,45 @@ export async function getRFC(projectId, slug, collectionId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}
// §22 S2: the collections of a project (for the /p/<project>/ directory).
// §22 S2: the collections of a project (for the /p/<project>/ directory). The
// response also carries a `viewer` block (§22 S4 capability flags:
// can_create_collection, can_invite, role) driving the role-aware directory.
export async function listCollections(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
}
// §22 S4: one collection's settings + the viewer's collection-level
// capabilities (viewer.can_contribute / can_invite / role) — drives the
// propose-first empty state and the collection invite control.
export async function getCollection(projectId, collectionId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}`))
}
// §22 S4 (C.2): the scope-role membership surface. An Owner grants
// {owner, contributor} at the project, or at one collection (collectionId set),
// to an existing account by email; the backend writes the membership row and
// §15-notifies the grantee.
export async function listScopeMembers(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/members`))
}
export async function grantScopeMember(projectId, { email, role, collectionId }) {
const res = await fetch(`/api/projects/${projectId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role, collection_id: collectionId || null }),
})
return jsonOrThrow(res)
}
export async function revokeScopeMember(projectId, userId, collectionId) {
const qs = collectionId ? `?collection_id=${encodeURIComponent(collectionId)}` : ''
const res = await fetch(`/api/projects/${projectId}/members/${userId}${qs}`, {
method: 'DELETE',
})
return jsonOrThrow(res)
}
// §22 S2: create-collection (deployment owner/admin). The backend commits a
// .collection.yaml and re-mirrors the registry, returning the new collection.
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {