Fix #145: tree membership management (list / add / role / remove)

TreeMembership was enforced on every read/write but had no API or UI to manage
members — trees were effectively single-user, breaking full-CRUD (NN#8).

Backend (/trees/{id}/members): list (members only — the list exposes emails, so
non-members never see it, even on public trees); add an existing user by email
(owner only, 404 if no such account, 409 if already a member); PATCH role;
DELETE. A tree must always keep ≥1 owner (demote/remove of the sole owner → 409).
All changes audited.

Frontend: a Members page (owner gets add-by-email + per-member role select +
remove; others see a read-only list) and a sidebar entry.

Test covers the full lifecycle + every guard. Suite 77 passed.

Closes #145

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-09 12:43:30 -04:00
parent 6d3147e86d
commit eb0350733b
9 changed files with 991 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
"""Tree membership management: list, add-by-email, role change, remove, guards."""
from tests.conftest import auth, register
async def test_membership_management(client):
owner = auth(await register(client, "mm-owner@ex.com"))
ed = auth(await register(client, "mm-editor@ex.com"))
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=owner)).json()["id"]
# A non-member can't even see the member list of a private tree.
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403
# Add a non-existent user → 404.
assert (
await client.post(
f"/api/v1/trees/{tid}/members",
json={"email": "ghost@ex.com", "role": "editor"},
headers=owner,
)
).status_code == 404
# Add the editor by email.
r = await client.post(
f"/api/v1/trees/{tid}/members",
json={"email": "mm-editor@ex.com", "role": "editor"},
headers=owner,
)
assert r.status_code == 201, r.text
mid = r.json()["id"]
assert r.json()["email"] == "mm-editor@ex.com" and r.json()["role"] == "editor"
# Adding the same user again → 409.
assert (
await client.post(
f"/api/v1/trees/{tid}/members",
json={"email": "mm-editor@ex.com", "role": "viewer"},
headers=owner,
)
).status_code == 409
# The editor can now see the tree's member list (2 members)...
ml = (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).json()
assert len(ml) == 2
owner_mid = next(m["id"] for m in ml if m["role"] == "owner")
# ...but a non-owner can't manage members.
assert (
await client.post(
f"/api/v1/trees/{tid}/members",
json={"email": "mm-owner@ex.com", "role": "viewer"},
headers=ed,
)
).status_code == 403
# Owner changes the editor's role.
pr = await client.patch(
f"/api/v1/trees/{tid}/members/{mid}", json={"role": "viewer"}, headers=owner
)
assert pr.status_code == 200 and pr.json()["role"] == "viewer"
# The sole owner can't be demoted or removed.
assert (
await client.patch(
f"/api/v1/trees/{tid}/members/{owner_mid}", json={"role": "editor"}, headers=owner
)
).status_code == 409
assert (
await client.delete(f"/api/v1/trees/{tid}/members/{owner_mid}", headers=owner)
).status_code == 409
# Owner removes the editor; the list shrinks and the editor loses access.
assert (await client.delete(f"/api/v1/trees/{tid}/members/{mid}", headers=owner)).status_code == 204
assert len((await client.get(f"/api/v1/trees/{tid}/members", headers=owner)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403