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
+2
View File
@@ -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)
+61
View File
@@ -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
)
+26
View File
@@ -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
+156
View File
@@ -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()
+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
+168
View File
@@ -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<Member[]>([]);
const [meId, setMeId] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("viewer");
const [msg, setMsg] = useState<string | null>(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 <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Members</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Who can see and edit this tree. {isOwner ? "You're the owner." : `Your role: ${myRole}.`}
</p>
</div>
{isOwner && (
<Card>
<CardContent className="p-5">
<form onSubmit={addMember} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Add by email (must have an account)</span>
<Input
className="w-72"
type="email"
placeholder="person@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<select
className="h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="owner">Owner</option>
</select>
<Button type="submit" disabled={!email.trim()}>
Add
</Button>
</form>
{msg && <p className="mt-2 text-sm text-bronze">{msg}</p>}
</CardContent>
</Card>
)}
<Card className="overflow-hidden">
<CardContent className="p-0">
{members.map((m, i) => (
<div
key={m.id}
className={`flex flex-wrap items-center justify-between gap-3 px-4 py-3 ${
i > 0 ? "border-t border-[var(--border)]" : ""
}`}
>
<div className="min-w-0">
<div className="truncate font-medium">{m.display_name || m.email}</div>
<div className="truncate text-xs text-[var(--muted)]">{m.email}</div>
</div>
{isOwner ? (
<div className="flex items-center gap-2">
<select
className="h-8 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm capitalize"
value={m.role}
onChange={(e) => changeRole(m.id, e.target.value as Role)}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
onClick={() => remove(m.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove member"
title="Remove member"
>
×
</button>
</div>
) : (
<span className="text-sm capitalize text-[var(--muted)]">{m.role}</span>
)}
</div>
))}
</CardContent>
</Card>
</div>
);
}
+7
View File
@@ -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`)}
/>
<Item
href={`/trees/${treeId}/members`}
label="Members"
icon={UserPlus}
active={pathname.startsWith(`/trees/${treeId}/members`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
+207
View File
@@ -925,6 +925,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/members": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Members */
get: operations["list_members_api_v1_trees__tree_id__members_get"];
put?: never;
/** Add Member */
post: operations["add_member_api_v1_trees__tree_id__members_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/members/{membership_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove Member */
delete: operations["remove_member_api_v1_trees__tree_id__members__membership_id__delete"];
options?: never;
head?: never;
/** Update Member */
patch: operations["update_member_api_v1_trees__tree_id__members__membership_id__patch"];
trace?: never;
};
}
export type webhooks = Record<string, never>;
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"];
};
};
};
};
}
+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": {