Merge pull request '§22 S4: invitation surfaces + role-aware empty states — v0.43.0 (@S4)' (#21) from feat/s4-invitation-surfaces-empty-states into main
This commit is contained in:
@@ -23,6 +23,61 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.43.0 — 2026-06-06
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
|
||||
surfaces + role-aware empty states.* An Owner can now grant scope roles from the
|
||||
UI, and each tier shows a role-appropriate empty state. Purely additive over S3:
|
||||
new endpoints and UI, no schema migration, and no change to existing read or
|
||||
write semantics. Completes acceptance scenarios `@S4` (C2.1–C2.7: invitation
|
||||
reach, bounding, supersession, and the pending-account floor; C3.3–C3.5: the
|
||||
create-first-collection and propose-first empty states).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S4, Part C.2 and C.3). The in-app create-project flow and the
|
||||
global-directory empty states (C3.1–C3.2) remain S5.
|
||||
|
||||
Added:
|
||||
|
||||
- **Scope-role invitation surface (§22 C.2)** — `POST
|
||||
/api/projects/<id>/members` grants `{owner, contributor}` to an existing
|
||||
account (looked up by email) at the project, or at one collection (with
|
||||
`collection_id`). The grant writes a `memberships` row immediately and emits a
|
||||
§15 personal-direct notification (`scope_role_granted`) naming the project and
|
||||
role — a direct grant, not an accept round-trip. `GET` lists the project
|
||||
subtree's grants (project-Owner view); `DELETE
|
||||
/api/projects/<id>/members/<user_id>` (optionally `?collection_id=`) revokes.
|
||||
- **Invitation gates** — `auth.can_invite_at_project` /
|
||||
`can_invite_at_collection`: managing membership is an Owner capability bounded
|
||||
by the inviter's reach. A project Owner grants at the project or any collection
|
||||
within it; a collection Owner grants only at that collection (never the project
|
||||
or globally); an RFC Contributor manages no membership.
|
||||
- **Broader-scope-supersedes (§22 C.2.6)** — granting at a broader scope removes
|
||||
the grantee's narrower rows that the new grant subsumes (same-or-lower role
|
||||
rank within the subtree); a stronger child grant survives a weaker parent grant
|
||||
(no negative override).
|
||||
- **Pending-account floor (§22 C.2.7)** — a grant to a `pending` deployment
|
||||
account is recorded but confers no write until the account is granted at the
|
||||
deployment (the §6 admission floor in `effective_scope_role`).
|
||||
- **Viewer capability flags** — `GET /api/projects/<id>/collections` carries a
|
||||
`viewer` block (`can_create_collection`, `can_invite`, `role`); `GET
|
||||
/api/projects/<id>/collections/<cid>` carries `viewer.can_contribute /
|
||||
can_invite / role`. These drive the role-aware UI without a second round-trip.
|
||||
- **Role-aware empty states (§22 C.3.3–C.3.5)** — a project Owner landing on an
|
||||
empty project sees a "Create your first collection" CTA (opening the
|
||||
create-collection modal — surfacing the S2 endpoint, previously UI-less); a
|
||||
contributor without create rights sees the bare empty directory; a collection
|
||||
contributor landing on an empty collection sees "Propose the first entry"
|
||||
(anonymous readers keep the S2 sign-in prompt). The directory also gains an
|
||||
Owner-only **Members** control opening the invitation modal.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. No operator action is required. S4 adds endpoints and UI only — there is no
|
||||
migration and no change to existing authorization outcomes. A deployment that
|
||||
was managing `memberships` rows directly (the S3 administrative path) keeps
|
||||
working; the new UI is an additional way to write the same rows.
|
||||
|
||||
## 0.42.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
|
||||
|
||||
@@ -27,6 +27,7 @@ from . import (
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_memberships,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
@@ -154,6 +155,9 @@ def make_router(
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config))
|
||||
router.include_router(api_collections.make_router(config, gitea, bot))
|
||||
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
||||
# {owner, contributor} at project/collection scope to existing accounts.
|
||||
router.include_router(api_memberships.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
|
||||
@@ -41,6 +41,26 @@ class CreateCollectionBody(BaseModel):
|
||||
initial_state: str | None = None
|
||||
|
||||
|
||||
def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
|
||||
"""§22 S4: the viewer's project-grain capabilities for role-aware UI — may
|
||||
they create a collection, may they manage membership (invite), and their
|
||||
project role. `role` maps the §22.6 legacy strings back to the unified
|
||||
`{owner, contributor}` vocabulary the frontend speaks."""
|
||||
legacy = auth.project_member_role(viewer, project_id)
|
||||
role = None
|
||||
if viewer is not None and viewer.role in ("owner", "admin"):
|
||||
role = "owner"
|
||||
elif legacy == "project_admin":
|
||||
role = "owner"
|
||||
elif legacy == "project_contributor":
|
||||
role = "contributor"
|
||||
return {
|
||||
"can_create_collection": auth.can_create_collection(viewer, project_id),
|
||||
"can_invite": auth.can_invite_at_project(viewer, project_id),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@@ -57,7 +77,10 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
||||
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
||||
]
|
||||
return {"items": items}
|
||||
# §22 S4: surface the viewer's project-level capabilities so the
|
||||
# directory can render role-aware affordances (the create-first-
|
||||
# collection CTA, the invite control) without a second round-trip.
|
||||
return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)}
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
||||
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
||||
@@ -68,6 +91,14 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
raise HTTPException(404, "Not found")
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
# §22 S4: the viewer's collection-level capabilities drive the
|
||||
# propose-first empty state and the collection invite control.
|
||||
col = dict(col)
|
||||
col["viewer"] = {
|
||||
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
|
||||
"can_invite": auth.can_invite_at_collection(viewer, collection_id),
|
||||
"role": auth.effective_scope_role(viewer, collection_id),
|
||||
}
|
||||
return col
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections")
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""§22 S4 (C.2) — the scope-role invitation surface.
|
||||
|
||||
An Owner grants `{owner, contributor}` at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account, looked up
|
||||
by email. The grant writes a `memberships` row immediately and §15-notifies
|
||||
the grantee (there is no accept round-trip; the C.2 scenarios name an existing
|
||||
user and write the row directly). Endpoints:
|
||||
|
||||
GET /api/projects/:pid/members — list the project subtree's grants
|
||||
POST /api/projects/:pid/members — grant at project scope, or
|
||||
(with collection_id) at one collection
|
||||
DELETE /api/projects/:pid/members/:user_id — revoke (optionally ?collection_id=)
|
||||
|
||||
The single POST keys on the optional `collection_id` so the invite UI's one
|
||||
control (role picker + scope picker) maps to one endpoint:
|
||||
|
||||
* no `collection_id` → project-scope grant; gate `can_invite_at_project`.
|
||||
* with `collection_id` → collection-scope grant; gate `can_invite_at_collection`.
|
||||
|
||||
There is deliberately no "grant at parent, exclude a child" parameter (C.2.5):
|
||||
the only knobs are role ∈ {owner, contributor} and scope ∈ {project, one
|
||||
collection}. Reach is bounded by the inviter's own Owner reach (C.2.3): a
|
||||
collection Owner who is nothing more is refused the project-scope POST.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
|
||||
class GrantBody(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
collection_id: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/members")
|
||||
async def list_members(project_id: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
# The full subtree listing is a project-Owner view; a collection-only
|
||||
# Owner manages membership through the collection-scoped POST/DELETE.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
return {"items": memberships_mod.list_for_project(project_id)}
|
||||
|
||||
@router.post("/api/projects/{project_id}/members")
|
||||
async def grant_member(
|
||||
project_id: str, body: GrantBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
cid = (body.collection_id or "").strip() or None
|
||||
if cid is not None:
|
||||
# Collection-scope grant — bounded by Owner reach over that collection.
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
scope_type, scope_id = "collection", cid
|
||||
else:
|
||||
# Project-scope grant — bounded by Owner reach over the project.
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
scope_type, scope_id = "project", project_id
|
||||
|
||||
grantee = memberships_mod.user_by_email(body.email)
|
||||
if grantee is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
"No account with that email — the invitee must sign in to the "
|
||||
"deployment before they can be granted a role",
|
||||
)
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=grantee["id"],
|
||||
role=role,
|
||||
granted_by=user.user_id,
|
||||
)
|
||||
|
||||
# §15 (C.2): name the project and role to the grantee.
|
||||
col_name = None
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
col_name = (col.get("name") if col else None) or scope_id
|
||||
notify.notify_scope_role_granted(
|
||||
recipient_user_id=grantee["id"],
|
||||
granter_user_id=user.user_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
role=role,
|
||||
project_id=project_id,
|
||||
project_name=_project_name(project_id),
|
||||
collection_name=col_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"user_id": grantee["id"],
|
||||
"role": role,
|
||||
"pending": grantee["permission_state"] != "granted",
|
||||
}
|
||||
|
||||
@router.delete("/api/projects/{project_id}/members/{user_id}")
|
||||
async def revoke_member(
|
||||
project_id: str, user_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
cid = (request.query_params.get("collection_id") or "").strip() or None
|
||||
if cid is not None:
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
if not auth.can_invite_at_collection(user, cid):
|
||||
raise HTTPException(403, "You may not manage membership in this collection")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="collection", scope_id=cid, user_id=user_id
|
||||
)
|
||||
else:
|
||||
if not auth.can_invite_at_project(user, project_id):
|
||||
raise HTTPException(403, "You may not manage membership in this project")
|
||||
removed = memberships_mod.revoke(
|
||||
scope_type="project", scope_id=project_id, user_id=user_id
|
||||
)
|
||||
return {"removed": removed}
|
||||
|
||||
return router
|
||||
@@ -682,6 +682,26 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
|
||||
collection within it)? Managing membership is an *Owner* capability whose
|
||||
reach covers the project — a deployment owner/admin, a global Owner, or this
|
||||
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
|
||||
collection Owner's reach is its own collection only (C.2.3), so it is not
|
||||
offered project-scope invites. Identical to `is_project_superuser` — the
|
||||
invite gate IS "is an Owner over this project"."""
|
||||
return is_project_superuser(user, project_id)
|
||||
|
||||
|
||||
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
|
||||
whose reach covers it — the collection's Owner, its project's Owner, a global
|
||||
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
|
||||
narrowest invite reach; a collection Owner who is nothing more may invite
|
||||
here but not at the project or globally (C.2.3)."""
|
||||
return is_collection_superuser(user, collection_id)
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""§22 S4 (C.2) — scope-role grant operations over the `memberships` table.
|
||||
|
||||
The membership *gates* (who may invite, who holds which role) live in
|
||||
`auth.py`; this module holds the *mutations* the invitation surface drives —
|
||||
granting, the "broader scope supersedes narrower" cleanup, listing, and
|
||||
revocation — mirroring how `invites.py` owns the create/claim/list of per-user
|
||||
invite tokens while the gate (`auth.can_invite_to_rfc`) lives in `auth.py`.
|
||||
|
||||
The model (Part B / S3): a `memberships` row is `(scope_type ∈ {global,
|
||||
project, collection}, scope_id, user_id, role ∈ {owner, contributor})`, unique
|
||||
per `(scope_type, scope_id, user_id)`. A grant is a direct write of that row
|
||||
(the C.2 scenarios write the row immediately and §15-notify an existing
|
||||
account — there is no accept round-trip; inviting a not-yet-account email is
|
||||
out of S4 scope and handled by the admin-create-invite path).
|
||||
|
||||
The "broader scope supersedes narrower" rule (C.2.6): granting a role at a
|
||||
broader scope removes this user's narrower rows that the new grant *subsumes*
|
||||
— a narrower row whose role is no more permissive than the new one. A narrower
|
||||
row that is *more* permissive is kept (no negative override: a child Owner
|
||||
grant survives a parent Contributor grant, and the §B.2 resolver still unions
|
||||
most-permissively).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
|
||||
# Higher rank = more permissive. Used by the supersede rule: a narrower grant
|
||||
# is pruned only when its rank ≤ the new broader grant's rank.
|
||||
_ROLE_RANK = {"contributor": 1, "owner": 2}
|
||||
|
||||
VALID_SCOPE_TYPES = ("global", "project", "collection")
|
||||
VALID_ROLES = ("owner", "contributor")
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
|
||||
def user_by_email(email: str) -> dict[str, Any] | None:
|
||||
"""The `users` row (id, display_name, email, permission_state) for an
|
||||
email, case-insensitively, or None. The grantee must already be an account
|
||||
— S4 grants a scope role to an existing user, it does not provision one."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, display_name, email, permission_state, role "
|
||||
"FROM users WHERE lower(email) = lower(?) "
|
||||
"ORDER BY id LIMIT 1",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def grant(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
user_id: int,
|
||||
role: str,
|
||||
granted_by: int | None,
|
||||
) -> None:
|
||||
"""Write (or update) the membership row, then apply the C.2.6
|
||||
broader-scope-supersedes cleanup. Idempotent on `(scope_type, scope_id,
|
||||
user_id)` — re-granting at the same scope updates the role and the grantor.
|
||||
|
||||
The grant is recorded regardless of the grantee's deployment
|
||||
`permission_state`: a `pending` account's row is written (C.2.7), but the
|
||||
§6 admission floor in `auth.effective_scope_role` keeps it conferring no
|
||||
write until the account is granted at the deployment."""
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (scope_type, scope_id, user_id) "
|
||||
"DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, "
|
||||
"granted_at = datetime('now')",
|
||||
(scope_type, scope_id, user_id, role, granted_by),
|
||||
)
|
||||
_prune_subsumed(scope_type=scope_type, scope_id=scope_id, user_id=user_id, role=role)
|
||||
|
||||
|
||||
def _prune_subsumed(*, scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
"""Remove this user's narrower rows that the just-written broader grant
|
||||
subsumes (same-or-lower role rank within the broader scope's subtree). A
|
||||
collection grant subsumes nothing narrower (the per-entry tier is separate);
|
||||
a project grant subsumes its collections; a global grant subsumes every
|
||||
project and collection."""
|
||||
rank = _ROLE_RANK[role]
|
||||
keep_ranks = [r for r, v in _ROLE_RANK.items() if v <= rank]
|
||||
if not keep_ranks:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in keep_ranks)
|
||||
if scope_type == "project":
|
||||
# Narrower = collection-scope rows for collections in this project.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type = 'collection' "
|
||||
f" AND role IN ({placeholders}) "
|
||||
f" AND scope_id IN (SELECT id FROM collections WHERE project_id = ?)",
|
||||
(user_id, *keep_ranks, scope_id),
|
||||
)
|
||||
elif scope_type == "global":
|
||||
# Narrower = every project- and collection-scope row for this user.
|
||||
db.conn().execute(
|
||||
f"DELETE FROM memberships "
|
||||
f"WHERE user_id = ? AND scope_type IN ('project', 'collection') "
|
||||
f" AND role IN ({placeholders})",
|
||||
(user_id, *keep_ranks),
|
||||
)
|
||||
|
||||
|
||||
def revoke(*, scope_type: str, scope_id: str, user_id: int) -> bool:
|
||||
"""Remove a membership row at exactly this scope. Returns True if a row was
|
||||
removed. Revocation is scope-exact: it does not cascade to broader or
|
||||
narrower grants (each is its own administrative act)."""
|
||||
cur = db.conn().execute(
|
||||
"DELETE FROM memberships WHERE scope_type = ? AND scope_id = ? AND user_id = ?",
|
||||
(scope_type, scope_id, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def list_for_project(project_id: str) -> list[dict[str, Any]]:
|
||||
"""Every project-scope grant on this project plus every collection-scope
|
||||
grant on its collections, joined to the grantee's display fields — the data
|
||||
behind the project-Owner membership panel. Ordered project grants first,
|
||||
then by collection, then by role (Owner before Contributor)."""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.scope_type, m.scope_id, m.user_id, m.role, m.granted_at,
|
||||
u.display_name, u.email, u.permission_state,
|
||||
c.name AS collection_name
|
||||
FROM memberships m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN collections c ON c.id = m.scope_id AND m.scope_type = 'collection'
|
||||
WHERE (m.scope_type = 'project' AND m.scope_id = ?)
|
||||
OR (m.scope_type = 'collection'
|
||||
AND m.scope_id IN (SELECT id FROM collections WHERE project_id = ?))
|
||||
ORDER BY (m.scope_type != 'project'), m.scope_id,
|
||||
CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, u.display_name
|
||||
""",
|
||||
(project_id, project_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -324,6 +324,48 @@ def fan_out_contribution_request(
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_scope_role_granted(
|
||||
*,
|
||||
recipient_user_id: int,
|
||||
granter_user_id: int | None,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
role: str,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
collection_name: str | None,
|
||||
) -> int | None:
|
||||
"""§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
|
||||
Personal-direct — the recipient is the named subject — so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events.
|
||||
The scope facts ride in the payload so the inbox row (and email body) names
|
||||
the project and role without a second fetch. Actor is the granter (§15.9);
|
||||
a system/administrative grant with no granter renders as "the app".
|
||||
|
||||
Returns the notification id, or None when the grantee would be notifying
|
||||
themselves (a self-grant — no notification)."""
|
||||
if granter_user_id is not None and recipient_user_id == granter_user_id:
|
||||
return None
|
||||
details = {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"role": role,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"collection_name": collection_name or "",
|
||||
}
|
||||
return _emit_one(
|
||||
recipient_user_id=recipient_user_id,
|
||||
event_kind="scope_role_granted",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=granter_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def notify_contribution_decided(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
@@ -859,6 +901,19 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||||
if event_kind == "contribution_request_declined":
|
||||
return f"{actor} declined your request to contribute to {title}."
|
||||
if event_kind == "scope_role_granted":
|
||||
# §22 S4 (C.2): names the role and the scope (the project, and the
|
||||
# collection when collection-scoped) per "a §15 notification naming the
|
||||
# project and role".
|
||||
role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
|
||||
project_label = extras.get("project_name") or extras.get("project_id") or "a project"
|
||||
scope_type = extras.get("scope_type")
|
||||
if scope_type == "collection":
|
||||
col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
|
||||
return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
|
||||
if scope_type == "global":
|
||||
return f"{actor} granted you {role_label} across the whole deployment."
|
||||
return f"{actor} granted you {role_label} on project {project_label}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Slice S4 — invitation surfaces + role-aware empty states (@S4).
|
||||
|
||||
The acceptance gate for S4 is "every Part C.2 invitation scenario passes" (the
|
||||
design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.2,
|
||||
tagged @S4), plus the capability flags that drive the C.3 (@S4) role-aware
|
||||
empty states.
|
||||
|
||||
An Owner grants {owner, contributor} at a scope their reach covers — the
|
||||
project, or a single collection within it — to an existing account looked up by
|
||||
email. The grant writes a `memberships` row immediately and §15-notifies the
|
||||
grantee (no accept round-trip). Reach is bounded by the inviter's Owner reach;
|
||||
re-granting at a broader scope supersedes the narrower row; a `pending`
|
||||
deployment account's grant is recorded but confers no write.
|
||||
|
||||
Background (C.2): project "ohm" owns collections "model" (document) and
|
||||
"features" (bdd). eve is project Owner of ohm; dan is collection Owner of
|
||||
features only; ben is a project Contributor of ohm. ivy / jo / jet are
|
||||
grantees; kim is a pending deployment account.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (mirror the S3 vertical's world-builders)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
|
||||
from app import auth
|
||||
|
||||
return auth.SessionUser(
|
||||
user_id=user_id, gitea_id=user_id, gitea_login=login,
|
||||
display_name=login.capitalize(), email=f"{login}@test", avatar_url="",
|
||||
role=role, permission_state=state,
|
||||
)
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "public", subfolder: str | None = None) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
||||
visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
"""The set of (scope_type, scope_id, role) rows a user holds."""
|
||||
from app import db
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "public")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
# the cast
|
||||
provision_user_row(user_id=2, login="ben", role="contributor")
|
||||
provision_user_row(user_id=4, login="dan", role="contributor")
|
||||
provision_user_row(user_id=5, login="eve", role="contributor")
|
||||
provision_user_row(user_id=10, login="ivy", role="contributor")
|
||||
provision_user_row(user_id=11, login="jo", role="contributor")
|
||||
provision_user_row(user_id=12, login="jet", role="contributor")
|
||||
provision_user_row(user_id=13, login="kim", role="contributor")
|
||||
_grant("project", "ohm", 5, "owner") # eve — project Owner
|
||||
_grant("collection", "features", 4, "owner") # dan — collection Owner only
|
||||
_grant("project", "ohm", 2, "contributor") # ben — project Contributor
|
||||
# kim is a pending deployment account.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE users SET permission_state = 'pending' WHERE id = 13"
|
||||
)
|
||||
|
||||
|
||||
def _login_eve(client) -> None:
|
||||
sign_in_as(client, user_id=5, gitea_login="eve", display_name="Eve", role="contributor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.2 — invitation: who may invite whom, at which scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c2_1_project_owner_invites_at_project_scope(app_with_fake_gitea):
|
||||
"""A project Owner grants at project scope; the grant covers every
|
||||
collection, and the grantee is §15-notified naming the project and role."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
# a membership row is written at scope project "ohm"
|
||||
assert ("project", "ohm", "contributor") in _membership(10)
|
||||
# ivy may propose in every collection of "ohm"
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "model") is True
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
# ivy receives a §15 notification naming the project and role
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
granted = [n for n in inbox if n["event_kind"] == "scope_role_granted"]
|
||||
assert granted, inbox
|
||||
assert "Ohm" in granted[0]["summary"]
|
||||
assert "RFC Contributor" in granted[0]["summary"]
|
||||
|
||||
|
||||
def test_c2_2_owner_invites_at_specific_collection(app_with_fake_gitea):
|
||||
"""A grant at a single collection scope reaches that collection only."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jo@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(11)
|
||||
jo = _su(11, "jo")
|
||||
assert auth.can_contribute_in_collection(jo, "features") is True
|
||||
assert auth.can_contribute_in_collection(jo, "model") is False
|
||||
|
||||
|
||||
def test_c2_3_invitation_reach_bounded_by_inviter_scope(app_with_fake_gitea):
|
||||
"""A collection Owner may invite within that collection, but is not offered
|
||||
(is refused) the control to invite at the project or globally."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan is Owner at collection "features" only.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
# may grant at his collection
|
||||
ok = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
# but not at the project scope
|
||||
no = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no.status_code == 403, no.text
|
||||
# the capability flags the UI reads agree: no project invite, yes collection
|
||||
proj = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert proj["can_invite"] is False
|
||||
col = client.get("/api/projects/ohm/collections/features").json()["viewer"]
|
||||
assert col["can_invite"] is True
|
||||
|
||||
|
||||
def test_c2_4_contributors_do_not_manage_membership(app_with_fake_gitea):
|
||||
"""An RFC Contributor (project- or collection-scoped) holds no invite
|
||||
capability and the grant endpoints refuse them."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben is a project *Contributor* on ohm.
|
||||
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
||||
no_proj = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor"})
|
||||
assert no_proj.status_code == 403, no_proj.text
|
||||
no_col = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"collection_id": "features"})
|
||||
assert no_col.status_code == 403, no_col.text
|
||||
# no invite control surfaced anywhere
|
||||
assert client.get("/api/projects/ohm/collections").json()["viewer"]["can_invite"] is False
|
||||
assert client.get("/api/projects/ohm/collections/features").json()["viewer"]["can_invite"] is False
|
||||
|
||||
|
||||
def test_c2_5_no_grant_at_parent_revoke_at_child_option(app_with_fake_gitea):
|
||||
"""The grant surface offers only role + scope (project or one collection);
|
||||
there is no way to grant at the project yet carve out a child collection —
|
||||
a project grant reaches every collection, full stop."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
# the only knobs are role and an optional single collection_id; an
|
||||
# "exclude" field has no effect (it is not part of the contract).
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "ivy@test", "role": "contributor",
|
||||
"exclude_collection_id": "features"})
|
||||
assert r.status_code == 200, r.text
|
||||
# the project grant still reaches the supposedly-excluded collection
|
||||
ivy = _su(10, "ivy")
|
||||
assert auth.can_contribute_in_collection(ivy, "features") is True
|
||||
|
||||
|
||||
def test_c2_6_broader_scope_supersedes_narrower(app_with_fake_gitea):
|
||||
"""Re-granting at a broader scope removes the subsumed narrower row; the
|
||||
grantee holds the role across the whole project."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "contributor") # jet starts narrow
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
# the project grant is present…
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# …and the redundant collection-scope row is gone (subsumed)
|
||||
assert ("collection", "features", "contributor") not in rows
|
||||
jet = _su(12, "jet")
|
||||
assert auth.can_contribute_in_collection(jet, "model") is True
|
||||
assert auth.can_contribute_in_collection(jet, "features") is True
|
||||
|
||||
|
||||
def test_c2_6b_narrower_stronger_role_is_not_subtracted(app_with_fake_gitea):
|
||||
"""No negative override: a child Owner grant survives a parent Contributor
|
||||
grant (the stronger collection role is kept)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "features", 12, "owner") # jet is collection Owner
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "jet@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
rows = _membership(12)
|
||||
assert ("project", "ohm", "contributor") in rows
|
||||
# the stronger collection-Owner row is NOT pruned by a weaker project grant
|
||||
assert ("collection", "features", "owner") in rows
|
||||
|
||||
|
||||
def test_c2_7_pending_account_grant_confers_no_write(app_with_fake_gitea):
|
||||
"""A grant to a pending deployment account is recorded but confers no write
|
||||
until the account is granted at the deployment (§6)."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
r = client.post("/api/projects/ohm/members",
|
||||
json={"email": "kim@test", "role": "contributor"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["pending"] is True
|
||||
# the grant row is recorded…
|
||||
assert ("project", "ohm", "contributor") in _membership(13)
|
||||
# …but confers no write while pending (the §6 admission floor)
|
||||
kim = _su(13, "kim", state="pending")
|
||||
assert auth.effective_scope_role(kim, "model") is None
|
||||
assert auth.can_contribute_in_collection(kim, "model") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.3 (@S4) — the capability flags behind the role-aware empty states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c3_3_project_owner_sees_create_first_collection_capability(app_with_fake_gitea):
|
||||
"""C3.3: a project Owner landing on an empty project may create a
|
||||
collection — the flag the 'Create your first collection' CTA reads."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login_eve(client)
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is True
|
||||
assert caps["role"] == "owner"
|
||||
|
||||
|
||||
def test_c3_4_contributor_without_create_rights_has_no_create_capability(app_with_fake_gitea):
|
||||
"""C3.4: a contributor whose only grant is at a collection elsewhere has no
|
||||
create-collection capability — the empty directory shows no create action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# dan holds only a collection-scope grant (features); no project create right.
|
||||
sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections").json()["viewer"]
|
||||
assert caps["can_create_collection"] is False
|
||||
|
||||
|
||||
def test_c3_5_collection_contributor_sees_propose_first_capability(app_with_fake_gitea):
|
||||
"""C3.5: a collection contributor landing on an empty collection may propose
|
||||
— the flag the 'Propose the first entry' CTA reads; an anonymous reader may
|
||||
not (the sign-in prompt path, already shipped in S2)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_grant("collection", "model", 10, "contributor") # ivy contributes in model
|
||||
sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
|
||||
caps = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps["can_contribute"] is True
|
||||
# anonymous reader: no propose capability
|
||||
client.cookies.clear()
|
||||
caps_anon = client.get("/api/projects/ohm/collections/model").json()["viewer"]
|
||||
assert caps_anon["can_contribute"] is False
|
||||
@@ -581,13 +581,17 @@ means the deployment runs and either gains a capability or provably loses none
|
||||
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
|
||||
inheritance, union, no-negative-override).
|
||||
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** *(Shipped v0.43.0.)*
|
||||
The invite UI
|
||||
(Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it,
|
||||
with §15 notifications and the broader-scope-supersedes rule; the
|
||||
create-first-collection / propose-first empty states keyed to the actor's role.
|
||||
**Usable end-state:** an Owner invites collaborators at the right scope from
|
||||
the UI. **Completes:** `@S4` (all of C.2 — invitation; plus the project/
|
||||
collection empty states C3.3–C3.5).
|
||||
Modelled as a **direct grant** to an existing account looked up by email (the
|
||||
C.2 scenarios write the membership row immediately and §15-notify an existing
|
||||
user — no accept round-trip; inviting a not-yet-account email is out of S4
|
||||
scope, handled by the admin-create-invite path). **Usable end-state:** an Owner
|
||||
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
|
||||
of C.2 — invitation; plus the project/collection empty states C3.3–C3.5).
|
||||
|
||||
- **S5 — In-app create-project + the global directory.** The global-Owner
|
||||
**create-project** action (bot provisions a Gitea content repo + commits to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+35
-1
@@ -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 }) {
|
||||
|
||||
@@ -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