§22 S4: scope-role invitation backend + capability flags (@S4 C.2)
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).
- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
preserving a stronger child grant — no negative override), revoke, list,
user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
POST keying on optional collection_id so the invite UI's one control maps
to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
contributors refused (C.2.4); a pending grantee's row is recorded but
confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
can_invite, can_contribute, role) on the project/collection GETs to drive
the C.3 role-aware empty states.
Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user