diff --git a/frontend/src/App.css b/frontend/src/App.css index c2e3f94..8201f3f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2615,3 +2615,25 @@ select:focus-visible, outline-offset: 2px; border-radius: var(--radius-sm); } + +/* §22 S4 — the collection directory head (title + owner controls) and the + role-aware empty state. The S2 directory shipped with semantic classnames + and default styling; S4 adds the action row and the create CTA. */ +.directory-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.directory-actions { + display: flex; + align-items: center; + gap: 10px; +} +.directory-empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} diff --git a/frontend/src/api.js b/frontend/src/api.js index d64efd8..7a191e1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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// directory). +// §22 S2: the collections of a project (for the /p// 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 }) { diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index 607ae06..e147ac4 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -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 ? (
{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.'}
) : ( @@ -154,7 +173,12 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
{viewer ? ( - + // §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 && ( + + ) ) : ( Sign in to propose Beta diff --git a/frontend/src/components/CollectionDirectory.jsx b/frontend/src/components/CollectionDirectory.jsx index 1b8b762..bc5835e 100644 --- a/frontend/src/components/CollectionDirectory.jsx +++ b/frontend/src/components/CollectionDirectory.jsx @@ -2,23 +2,39 @@ // project's caller-visible collections as cards linking into each collection's // `/p//c//` home. When exactly one collection is visible // the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8 -// single-collection UX, preserved). The role-keyed "Create your first -// collection" empty state is S4; S2 shows a minimal note when there are none. +// single-collection UX, preserved). +// +// §22 S4 — role-aware empty states + owner controls. The list response carries +// a `viewer` capability block; the directory reads it to render: +// * C3.3: a project Owner sees a "Create your first collection" CTA (and a +// "New collection" control when the directory is non-empty), opening the +// create-collection modal (choose a type + id + visibility). +// * C3.4: a contributor without create rights sees the empty directory with +// no create action. +// * C.2: an Owner with membership-management reach sees a "Members" control +// opening the scope-role invitation modal. import { useEffect, useState } from 'react' import { Link, Navigate } from 'react-router-dom' import { listCollections } from '../api' import { collectionHome } from '../lib/entryPaths' import { entryNoun } from './ProjectLayout.jsx' +import CreateCollectionModal from './CreateCollectionModal.jsx' +import ScopeMembersModal from './ScopeMembersModal.jsx' export default function CollectionDirectory({ projectId }) { const [cols, setCols] = useState(null) + const [viewer, setViewer] = useState(null) + const [version, setVersion] = useState(0) + const [createOpen, setCreateOpen] = useState(false) + const [membersOpen, setMembersOpen] = useState(false) + useEffect(() => { let live = true listCollections(projectId) - .then(d => { if (live) setCols(d.items) }) - .catch(() => { if (live) setCols([]) }) + .then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } }) + .catch(() => { if (live) { setCols([]); setViewer(null) } }) return () => { live = false } - }, [projectId]) + }, [projectId, version]) if (cols === null) { return
Loading…
@@ -27,12 +43,36 @@ export default function CollectionDirectory({ projectId }) { if (cols.length === 1) { return } + + const canCreate = !!viewer?.can_create_collection + const canInvite = !!viewer?.can_invite + return (
-

Collections

+
+

Collections

+
+ {canInvite && ( + + )} + {canCreate && cols.length > 0 && ( + + )} +
+
{cols.length === 0 ? ( -

No collections yet.

+ // C3.3 / C3.4: role-keyed empty state. + canCreate ? ( +
+

No collections yet.

+ +
+ ) : ( +

No collections yet.

+ ) ) : (
    {cols.map(c => ( @@ -46,6 +86,21 @@ export default function CollectionDirectory({ projectId }) {
)}
+ {createOpen && ( + setCreateOpen(false)} + onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }} + /> + )} + {membersOpen && ( + setMembersOpen(false)} + /> + )}
) } diff --git a/frontend/src/components/CreateCollectionModal.jsx b/frontend/src/components/CreateCollectionModal.jsx new file mode 100644 index 0000000..3492af3 --- /dev/null +++ b/frontend/src/components/CreateCollectionModal.jsx @@ -0,0 +1,105 @@ +// CreateCollectionModal.jsx — §22 S2 endpoint, §22 S4 UI. The create-collection +// form the project directory's "Create your first collection" / "New +// collection" control opens. S2 shipped the POST +// /api/projects/:id/collections endpoint but left it UI-less; S4 surfaces it, +// gated on the viewer's `can_create_collection` capability. +// +// The form lets the Owner choose an id (a slug, not "default"), a type, an +// optional display name, and an optional visibility (defaulting to the +// project's). The backend commits a `.collection.yaml`, re-mirrors the +// registry, and returns the new collection; on success the caller refreshes +// the directory. + +import { useState } from 'react' +import { createCollection } from '../api' + +const TYPE_OPTIONS = [ + { value: 'document', label: 'Document — prose RFCs' }, + { value: 'specification', label: 'Specification' }, + { value: 'bdd', label: 'BDD — behaviour scenarios' }, +] + +const VISIBILITY_OPTIONS = [ + { value: '', label: 'Inherit from project' }, + { value: 'public', label: 'Public' }, + { value: 'unlisted', label: 'Unlisted (link-only)' }, + { value: 'gated', label: 'Gated (hidden from the public)' }, +] + +export default function CreateCollectionModal({ projectId, onClose, onCreated }) { + const [collectionId, setCollectionId] = useState('') + const [type, setType] = useState('document') + const [name, setName] = useState('') + const [visibility, setVisibility] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function handleCreate(e) { + e.preventDefault() + const cid = collectionId.trim().toLowerCase() + if (!cid) return + setSubmitting(true) + setError(null) + try { + const col = await createCollection(projectId, { + collectionId: cid, + type, + name: name.trim() || null, + visibility: visibility || null, + }) + onCreated?.(col) + } catch (err) { + setError(err.message || 'Failed to create the collection.') + } finally { + setSubmitting(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

New collection

+ +
+
+
+ + setCollectionId(e.target.value)} + placeholder="e.g. model, features" + autoFocus + required + /> + + + + setName(e.target.value)} + placeholder="e.g. The Model" + /> + + +
+ + {error && {error}} +
+
+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/ScopeMembersModal.jsx b/frontend/src/components/ScopeMembersModal.jsx new file mode 100644 index 0000000..f7bb609 --- /dev/null +++ b/frontend/src/components/ScopeMembersModal.jsx @@ -0,0 +1,224 @@ +// ScopeMembersModal.jsx — §22 S4 (C.2): the Owner-only scope-role invitation +// surface, sibling of the per-RFC InvitationsModal. +// +// An Owner grants {owner, contributor} at a scope their reach covers — the +// project, or a single collection within it — to an existing account by email. +// The grant writes the membership row immediately and §15-notifies the grantee +// (no accept round-trip). The modal opens from the project directory's +// owner-only "Members" control. +// +// Two stacked sections, mirroring InvitationsModal: +// +// 1. "Grant a role" — email + role picker (Owner | RFC Contributor) + scope +// picker (the project, or one collection). The scope options are bounded +// to what the inviter may grant: a project Owner may pick the project or +// any collection; a collection-only Owner sees only their collection(s), +// never the project (C.2.3). There is no "grant at the project but exclude +// a child" option (C.2.5) — only role and a single scope. +// +// 2. "Current members" — the project subtree's grants, with revoke buttons. +// +// RFC Contributors never see the trigger that opens this modal (C.2.4); the +// directory gates it on the viewer's `can_invite` flag. + +import { useEffect, useMemo, useState } from 'react' +import { grantScopeMember, listScopeMembers, revokeScopeMember } from '../api' + +const ROLE_OPTIONS = [ + { value: 'contributor', label: 'RFC Contributor — may propose entries in the scope' }, + { value: 'owner', label: 'Owner — administers the scope and its membership' }, +] + +export default function ScopeMembersModal({ projectId, collections, viewer, onClose }) { + const [members, setMembers] = useState(null) + const [loadError, setLoadError] = useState(null) + const [email, setEmail] = useState('') + const [role, setRole] = useState('contributor') + const [scope, setScope] = useState('project') // 'project' | a collection id + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [submitSuccess, setSubmitSuccess] = useState(null) + const [revokingKey, setRevokingKey] = useState(null) + + // The project-scope option is offered only to an inviter whose reach covers + // the project (can_invite at project level); a collection-only Owner gets the + // collection options alone (C.2.3). Each collection is offered only when the + // viewer may invite there (its own `can_invite` flag). + const canInviteAtProject = !!viewer?.can_invite + const scopeOptions = useMemo(() => { + const opts = [] + if (canInviteAtProject) { + opts.push({ value: 'project', label: 'The whole project (every collection)' }) + } + for (const c of collections || []) { + if (c.viewer_can_invite || canInviteAtProject) { + opts.push({ value: c.id, label: `Collection — ${c.name || c.id}` }) + } + } + return opts + }, [collections, canInviteAtProject]) + + // Default the scope picker to the first allowed option. + useEffect(() => { + if (scopeOptions.length && !scopeOptions.some(o => o.value === scope)) { + setScope(scopeOptions[0].value) + } + }, [scopeOptions]) // eslint-disable-line react-hooks/exhaustive-deps + + async function refresh() { + setLoadError(null) + try { + const r = await listScopeMembers(projectId) + setMembers(r.items || []) + } catch (e) { + // A collection-only Owner is not authorized for the project-wide list; + // that is expected — they manage their collection via grant/revoke. + setMembers([]) + setLoadError(e.message) + } + } + + useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [projectId]) + + async function handleGrant(e) { + e.preventDefault() + const addr = email.trim() + if (!addr) return + setSubmitting(true) + setSubmitError(null) + setSubmitSuccess(null) + try { + await grantScopeMember(projectId, { + email: addr, + role, + collectionId: scope === 'project' ? null : scope, + }) + const where = scope === 'project' ? 'the project' : `collection ${scope}` + setSubmitSuccess(`Granted ${addr} on ${where}.`) + setEmail('') + await refresh() + } catch (err) { + setSubmitError(err.message || 'Failed to grant the role.') + } finally { + setSubmitting(false) + } + } + + async function handleRevoke(m) { + const collectionId = m.scope_type === 'collection' ? m.scope_id : null + const key = `${m.scope_type}:${m.scope_id}:${m.user_id}` + setRevokingKey(key) + try { + await revokeScopeMember(projectId, m.user_id, collectionId) + await refresh() + } catch (err) { + setSubmitError(err.message || 'Failed to revoke.') + } finally { + setRevokingKey(null) + } + } + + function scopeLabel(m) { + if (m.scope_type === 'project') return 'Project' + return `Collection · ${m.collection_name || m.scope_id}` + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Members

+ +
+
+

+ Grant collaborators a role at this project or a single collection + within it. An RFC Contributor may propose entries + in the scope; an Owner administers the scope and + invites others. A grant at the project covers every collection. +

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

Current members

+ {members === null &&
Loading…
} + {members !== null && members.length === 0 && ( +
+ {loadError ? 'Membership list is available to project Owners.' : 'No scope roles granted yet.'} +
+ )} + {members !== null && members.length > 0 && ( + + + + + + + + + + + {members.map(m => { + const key = `${m.scope_type}:${m.scope_id}:${m.user_id}` + return ( + + + + + + + ) + })} + +
MemberRoleScope
+ {m.display_name || m.email} + {m.permission_state !== 'granted' && ( + (pending) + )} + {m.role === 'owner' ? 'Owner' : 'RFC Contributor'}{scopeLabel(m)} + +
+ )} +
+
+ +
+
+
+ ) +}