diff --git a/CHANGELOG.md b/CHANGELOG.md index b56ae4b..d0863e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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//members/` (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//collections` carries a + `viewer` block (`can_create_collection`, `can_invite`, `role`); `GET + /api/projects//collections/` 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: diff --git a/VERSION b/VERSION index 787ffc3..8298bb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.0 +0.43.0 diff --git a/backend/app/api.py b/backend/app/api.py index 60be3f5..34cce3c 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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. diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py index 2f990d4..61132ad 100644 --- a/backend/app/api_collections.py +++ b/backend/app/api_collections.py @@ -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") diff --git a/backend/app/api_memberships.py b/backend/app/api_memberships.py new file mode 100644 index 0000000..296f448 --- /dev/null +++ b/backend/app/api_memberships.py @@ -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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 4f529ad..30e4b5d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/memberships.py b/backend/app/memberships.py new file mode 100644 index 0000000..aaba48c --- /dev/null +++ b/backend/app/memberships.py @@ -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] diff --git a/backend/app/notify.py b/backend/app/notify.py index 55a9a93..4427158 100644 --- a/backend/app/notify.py +++ b/backend/app/notify.py @@ -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 diff --git a/backend/tests/test_s4_invitations_vertical.py b/backend/tests/test_s4_invitations_vertical.py new file mode 100644 index 0000000..91e214a --- /dev/null +++ b/backend/tests/test_s4_invitations_vertical.py @@ -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 diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index 4592e3f..6498e6a 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 1bb033b..29152e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.42.0", + "version": "0.43.0", "type": "module", "scripts": { "dev": "vite", 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)} + +
+ )} +
+
+ +
+
+
+ ) +}