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
+290
View File
@@ -3537,6 +3537,211 @@
}
}
}
},
"/api/v1/trees/{tree_id}/members": {
"get": {
"tags": [
"members"
],
"summary": "List Members",
"operationId": "list_members_api_v1_trees__tree_id__members_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MembershipRead"
},
"title": "Response List Members Api V1 Trees Tree Id Members Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"members"
],
"summary": "Add Member",
"operationId": "add_member_api_v1_trees__tree_id__members_post",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberAdd"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MembershipRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/members/{membership_id}": {
"patch": {
"tags": [
"members"
],
"summary": "Update Member",
"operationId": "update_member_api_v1_trees__tree_id__members__membership_id__patch",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "membership_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Membership Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberRoleUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MembershipRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"members"
],
"summary": "Remove Member",
"operationId": "remove_member_api_v1_trees__tree_id__members__membership_id__delete",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "membership_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Membership Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
@@ -4737,6 +4942,91 @@
"type": "object",
"title": "MediaUpdate"
},
"MemberAdd": {
"properties": {
"email": {
"type": "string",
"title": "Email"
},
"role": {
"$ref": "#/components/schemas/MembershipRole",
"default": "viewer"
}
},
"type": "object",
"required": [
"email"
],
"title": "MemberAdd"
},
"MemberRoleUpdate": {
"properties": {
"role": {
"$ref": "#/components/schemas/MembershipRole"
}
},
"type": "object",
"required": [
"role"
],
"title": "MemberRoleUpdate"
},
"MembershipRead": {
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Id"
},
"user_id": {
"type": "string",
"format": "uuid",
"title": "User Id"
},
"email": {
"type": "string",
"title": "Email"
},
"display_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Display Name"
},
"role": {
"$ref": "#/components/schemas/MembershipRole"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"user_id",
"email",
"display_name",
"role",
"created_at"
],
"title": "MembershipRead"
},
"MembershipRole": {
"type": "string",
"enum": [
"owner",
"editor",
"viewer"
],
"title": "MembershipRole"
},
"NameApply": {
"properties": {
"edits": {