§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:
@@ -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>
|
||||
|
||||
@@ -2,23 +2,39 @@
|
||||
// project's caller-visible collections as cards linking into each collection's
|
||||
// `/p/<project>/c/<collection>/` 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 <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
@@ -27,12 +43,36 @@ export default function CollectionDirectory({ projectId }) {
|
||||
if (cols.length === 1) {
|
||||
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||
}
|
||||
|
||||
const canCreate = !!viewer?.can_create_collection
|
||||
const canInvite = !!viewer?.can_invite
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>Collections</h1>
|
||||
<div className="directory-head">
|
||||
<h1>Collections</h1>
|
||||
<div className="directory-actions">
|
||||
{canInvite && (
|
||||
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
|
||||
)}
|
||||
{canCreate && cols.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cols.length === 0 ? (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
// C3.3 / C3.4: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first collection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{cols.map(c => (
|
||||
@@ -46,6 +86,21 @@ export default function CollectionDirectory({ projectId }) {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateCollectionModal
|
||||
projectId={projectId}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }}
|
||||
/>
|
||||
)}
|
||||
{membersOpen && (
|
||||
<ScopeMembersModal
|
||||
projectId={projectId}
|
||||
collections={cols}
|
||||
viewer={viewer}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New collection</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="col-id">Collection id</label>
|
||||
<input
|
||||
id="col-id"
|
||||
value={collectionId}
|
||||
onChange={e => setCollectionId(e.target.value)}
|
||||
placeholder="e.g. model, features"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="col-type" style={{ marginTop: 10 }}>Type</label>
|
||||
<select id="col-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="col-name" style={{ marginTop: 10 }}>Display name (optional)</label>
|
||||
<input
|
||||
id="col-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. The Model"
|
||||
/>
|
||||
<label htmlFor="col-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="col-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create collection'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Members</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Grant collaborators a role at this project or a single collection
|
||||
within it. An <strong>RFC Contributor</strong> may propose entries
|
||||
in the scope; an <strong>Owner</strong> administers the scope and
|
||||
invites others. A grant at the project covers every collection.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleGrant} className="invitations-form" style={{ marginTop: 16 }}>
|
||||
<label htmlFor="grant-email">Email</label>
|
||||
<input
|
||||
id="grant-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="grant-role" style={{ marginTop: 10 }}>Role</label>
|
||||
<select id="grant-role" value={role} onChange={e => setRole(e.target.value)}>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="grant-scope" style={{ marginTop: 10 }}>Scope</label>
|
||||
<select id="grant-scope" value={scope} onChange={e => setScope(e.target.value)}>
|
||||
{scopeOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting || !scopeOptions.length}>
|
||||
{submitting ? 'Granting…' : 'Grant role'}
|
||||
</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' }}>Current members</h3>
|
||||
{members === null && <div>Loading…</div>}
|
||||
{members !== null && members.length === 0 && (
|
||||
<div style={{ color: '#666' }}>
|
||||
{loadError ? 'Membership list is available to project Owners.' : 'No scope roles granted yet.'}
|
||||
</div>
|
||||
)}
|
||||
{members !== null && members.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Member</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Scope</th>
|
||||
<th style={{ padding: 4 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(m => {
|
||||
const key = `${m.scope_type}:${m.scope_id}:${m.user_id}`
|
||||
return (
|
||||
<tr key={key} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 4 }}>
|
||||
{m.display_name || m.email}
|
||||
{m.permission_state !== 'granted' && (
|
||||
<span style={{ color: '#a60', marginLeft: 6 }}>(pending)</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 4 }}>{m.role === 'owner' ? 'Owner' : 'RFC Contributor'}</td>
|
||||
<td style={{ padding: 4, color: '#666' }}>{scopeLabel(m)}</td>
|
||||
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleRevoke(m)}
|
||||
disabled={revokingKey === key}
|
||||
>
|
||||
{revokingKey === key ? '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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user