From eb0350733b6409e854da78a2301ec3f9be316484 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 12:43:30 -0400 Subject: [PATCH] Fix #145: tree membership management (list / add / role / remove) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Justin Paul --- backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/members.py | 61 +++++ backend/app/schemas/membership.py | 26 ++ backend/app/services/membership_service.py | 156 +++++++++++ backend/tests/test_membership.py | 74 ++++++ frontend/app/trees/[id]/members/page.tsx | 168 ++++++++++++ frontend/components/app-sidebar.tsx | 7 + frontend/lib/api/schema.d.ts | 207 +++++++++++++++ frontend/openapi.json | 290 +++++++++++++++++++++ 9 files changed, 991 insertions(+) create mode 100644 backend/app/api/v1/members.py create mode 100644 backend/app/schemas/membership.py create mode 100644 backend/app/services/membership_service.py create mode 100644 backend/tests/test_membership.py create mode 100644 frontend/app/trees/[id]/members/page.tsx diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 244f870..5f17970 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -9,6 +9,7 @@ from app.api.v1 import ( events, gedcom, media, + members, names, persons, public, @@ -32,3 +33,4 @@ api_router.include_router(media.router) api_router.include_router(gedcom.router) api_router.include_router(cleanup.router) api_router.include_router(public.router) +api_router.include_router(members.router) diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py new file mode 100644 index 0000000..ded86c9 --- /dev/null +++ b/backend/app/api/v1/members.py @@ -0,0 +1,61 @@ +"""Tree membership management endpoints (owner-managed; members can list).""" + +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.membership import MemberAdd, MemberRoleUpdate, MembershipRead +from app.services import membership_service, tree_service + +router = APIRouter(prefix="/trees", tags=["members"]) + + +@router.get("/{tree_id}/members", response_model=list[MembershipRead]) +async def list_members( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[MembershipRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rows = await membership_service.list_members(session, viewer_id=current.id, tree=tree) + return [MembershipRead(**r) for r in rows] + + +@router.post( + "/{tree_id}/members", response_model=MembershipRead, status_code=status.HTTP_201_CREATED +) +async def add_member( + tree_id: uuid.UUID, data: MemberAdd, session: SessionDep, current: CurrentUser +) -> MembershipRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + row = await membership_service.add_member( + session, actor=current, tree=tree, email=data.email, role=data.role + ) + return MembershipRead(**row) + + +@router.patch("/{tree_id}/members/{membership_id}", response_model=MembershipRead) +async def update_member( + tree_id: uuid.UUID, + membership_id: uuid.UUID, + data: MemberRoleUpdate, + session: SessionDep, + current: CurrentUser, +) -> MembershipRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + row = await membership_service.update_member_role( + session, actor=current, tree=tree, membership_id=membership_id, role=data.role + ) + return MembershipRead(**row) + + +@router.delete("/{tree_id}/members/{membership_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_member( + tree_id: uuid.UUID, + membership_id: uuid.UUID, + session: SessionDep, + current: CurrentUser, +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await membership_service.remove_member( + session, actor=current, tree=tree, membership_id=membership_id + ) diff --git a/backend/app/schemas/membership.py b/backend/app/schemas/membership.py new file mode 100644 index 0000000..ce2654f --- /dev/null +++ b/backend/app/schemas/membership.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import MembershipRole + + +class MembershipRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + user_id: uuid.UUID + email: str + display_name: str | None + role: MembershipRole + created_at: datetime + + +class MemberAdd(BaseModel): + email: str + role: MembershipRole = MembershipRole.viewer + + +class MemberRoleUpdate(BaseModel): + role: MembershipRole diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py new file mode 100644 index 0000000..b8afe28 --- /dev/null +++ b/backend/app/services/membership_service.py @@ -0,0 +1,156 @@ +"""Tree membership management: list / add / change-role / remove. + +Only an owner may change membership. A tree must always keep at least one owner. +The member list (which exposes user emails) is visible only to members — never +to a non-member viewing a public/unlisted tree. +""" + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import MembershipRole +from app.models.tree import Tree, TreeMembership +from app.models.user import User +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Conflict, Forbidden, NotFound + + +async def _require_owner(session: AsyncSession, *, actor_id: uuid.UUID, tree: Tree) -> None: + if await privacy.get_membership_role(session, actor_id, tree.id) is not MembershipRole.owner: + raise Forbidden("only the owner can manage members") + + +async def _owner_count(session: AsyncSession, tree_id: uuid.UUID) -> int: + return ( + await session.execute( + select(func.count()) + .select_from(TreeMembership) + .where(TreeMembership.tree_id == tree_id, TreeMembership.role == MembershipRole.owner) + ) + ).scalar_one() + + +def _row(m: TreeMembership, u: User) -> dict: + return { + "id": m.id, + "user_id": u.id, + "email": u.email, + "display_name": u.display_name, + "role": m.role, + "created_at": m.created_at, + } + + +async def list_members(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[dict]: + # Member-only: the list exposes emails, so a non-member (even on a public + # tree) must not see it. + if await privacy.get_membership_role(session, viewer_id, tree.id) is None: + raise Forbidden("only members can see the member list") + rows = ( + await session.execute( + select(TreeMembership, User) + .join(User, User.id == TreeMembership.user_id) + .where(TreeMembership.tree_id == tree.id) + .order_by(TreeMembership.created_at) + ) + ).all() + return [_row(m, u) for m, u in rows] + + +async def add_member( + session: AsyncSession, *, actor: User, tree: Tree, email: str, role: MembershipRole +) -> dict: + await _require_owner(session, actor_id=actor.id, tree=tree) + user = ( + await session.execute( + select(User).where(User.email == email, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + if user is None: + raise NotFound("no user with that email on this instance") + if await privacy.get_membership_role(session, user.id, tree.id) is not None: + raise Conflict("that user is already a member") + m = TreeMembership(tree_id=tree.id, user_id=user.id, role=role) + session.add(m) + record_audit( + session, + action="add_member", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"user_id": str(user.id), "role": role.value}, + ) + await session.commit() + await session.refresh(m) + return _row(m, user) + + +async def _get_membership( + session: AsyncSession, tree: Tree, membership_id: uuid.UUID +) -> TreeMembership: + m = ( + await session.execute( + select(TreeMembership).where( + TreeMembership.id == membership_id, TreeMembership.tree_id == tree.id + ) + ) + ).scalar_one_or_none() + if m is None: + raise NotFound("member not found") + return m + + +async def update_member_role( + session: AsyncSession, + *, + actor: User, + tree: Tree, + membership_id: uuid.UUID, + role: MembershipRole, +) -> dict: + await _require_owner(session, actor_id=actor.id, tree=tree) + m = await _get_membership(session, tree, membership_id) + if ( + m.role == MembershipRole.owner + and role != MembershipRole.owner + and await _owner_count(session, tree.id) <= 1 + ): + raise Conflict("a tree must keep at least one owner") + m.role = role + record_audit( + session, + action="update_member", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"membership_id": str(m.id), "role": role.value}, + ) + await session.commit() + await session.refresh(m) + u = (await session.execute(select(User).where(User.id == m.user_id))).scalar_one() + return _row(m, u) + + +async def remove_member( + session: AsyncSession, *, actor: User, tree: Tree, membership_id: uuid.UUID +) -> None: + await _require_owner(session, actor_id=actor.id, tree=tree) + m = await _get_membership(session, tree, membership_id) + if m.role == MembershipRole.owner and await _owner_count(session, tree.id) <= 1: + raise Conflict("a tree must keep at least one owner") + await session.delete(m) + record_audit( + session, + action="remove_member", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"membership_id": str(membership_id)}, + ) + await session.commit() diff --git a/backend/tests/test_membership.py b/backend/tests/test_membership.py new file mode 100644 index 0000000..25e3d2c --- /dev/null +++ b/backend/tests/test_membership.py @@ -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 diff --git a/frontend/app/trees/[id]/members/page.tsx b/frontend/app/trees/[id]/members/page.tsx new file mode 100644 index 0000000..a1d54fa --- /dev/null +++ b/frontend/app/trees/[id]/members/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +type Member = components["schemas"]["MembershipRead"]; +type Role = components["schemas"]["MembershipRole"]; + +const ROLES: Role[] = ["owner", "editor", "viewer"]; + +export default function MembersPage() { + const { id: treeId } = useParams<{ id: string }>(); + const [members, setMembers] = useState([]); + const [meId, setMeId] = useState(null); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("viewer"); + const [msg, setMsg] = useState(null); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const [me, mem] = await Promise.all([ + api.GET("/api/v1/users/me"), + api.GET("/api/v1/trees/{tree_id}/members", { params: { path: { tree_id: treeId } } }), + ]); + setMeId(me.data?.id ?? null); + setMembers(mem.data ?? []); + setReady(true); + }, [treeId]); + + useEffect(() => { + load(); + }, [load]); + + const myRole = members.find((m) => m.user_id === meId)?.role; + const isOwner = myRole === "owner"; + + async function addMember(e: React.FormEvent) { + e.preventDefault(); + setMsg(null); + const { error, response } = await api.POST("/api/v1/trees/{tree_id}/members", { + params: { path: { tree_id: treeId } }, + body: { email: email.trim(), role }, + }); + if (error) { + setMsg( + response.status === 404 + ? "No account on this site has that email — they need to register first." + : response.status === 409 + ? "That person is already a member." + : "Couldn't add that member.", + ); + return; + } + setEmail(""); + load(); + } + + async function changeRole(membershipId: string, newRole: Role) { + const { error } = await api.PATCH("/api/v1/trees/{tree_id}/members/{membership_id}", { + params: { path: { tree_id: treeId, membership_id: membershipId } }, + body: { role: newRole }, + }); + if (error) setMsg("Couldn't change role (a tree must keep at least one owner)."); + load(); + } + + async function remove(membershipId: string) { + const { error } = await api.DELETE("/api/v1/trees/{tree_id}/members/{membership_id}", { + params: { path: { tree_id: treeId, membership_id: membershipId } }, + }); + if (error) setMsg("Couldn't remove (a tree must keep at least one owner)."); + load(); + } + + if (!ready) return

Loading…

; + + return ( +
+
+

Members

+

+ Who can see and edit this tree. {isOwner ? "You're the owner." : `Your role: ${myRole}.`} +

+
+ + {isOwner && ( + + +
+ + + +
+ {msg &&

{msg}

} +
+
+ )} + + + + {members.map((m, i) => ( +
0 ? "border-t border-[var(--border)]" : "" + }`} + > +
+
{m.display_name || m.email}
+
{m.email}
+
+ {isOwner ? ( +
+ + +
+ ) : ( + {m.role} + )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 2f4a96d..ea42f0d 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -11,6 +11,7 @@ import { Network, Settings, Sparkles, + UserPlus, Users, } from "lucide-react"; import Link from "next/link"; @@ -137,6 +138,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { icon={Sparkles} active={pathname.startsWith(`/trees/${treeId}/cleanup`)} /> + ; export interface components { @@ -1284,6 +1320,45 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** MemberAdd */ + MemberAdd: { + /** Email */ + email: string; + /** @default viewer */ + role?: components["schemas"]["MembershipRole"]; + }; + /** MemberRoleUpdate */ + MemberRoleUpdate: { + role: components["schemas"]["MembershipRole"]; + }; + /** MembershipRead */ + MembershipRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Email */ + email: string; + /** Display Name */ + display_name: string | null; + role: components["schemas"]["MembershipRole"]; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** + * MembershipRole + * @enum {string} + */ + MembershipRole: "owner" | "editor" | "viewer"; /** NameApply */ NameApply: { /** Edits */ @@ -4092,4 +4167,136 @@ export interface operations { }; }; }; + list_members_api_v1_trees__tree_id__members_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MembershipRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add_member_api_v1_trees__tree_id__members_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberAdd"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MembershipRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + remove_member_api_v1_trees__tree_id__members__membership_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + membership_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_member_api_v1_trees__tree_id__members__membership_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + membership_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRoleUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MembershipRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index bcc4b0a..9c67e34 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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": {