Merge pull request 'Fix #145: tree membership management (list / add / role / remove)' (#233) from membership-management into main
This commit was merged in pull request #233.
This commit is contained in:
@@ -9,6 +9,7 @@ from app.api.v1 import (
|
|||||||
events,
|
events,
|
||||||
gedcom,
|
gedcom,
|
||||||
media,
|
media,
|
||||||
|
members,
|
||||||
names,
|
names,
|
||||||
persons,
|
persons,
|
||||||
public,
|
public,
|
||||||
@@ -32,3 +33,4 @@ api_router.include_router(media.router)
|
|||||||
api_router.include_router(gedcom.router)
|
api_router.include_router(gedcom.router)
|
||||||
api_router.include_router(cleanup.router)
|
api_router.include_router(cleanup.router)
|
||||||
api_router.include_router(public.router)
|
api_router.include_router(public.router)
|
||||||
|
api_router.include_router(members.router)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -137,6 +138,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/members`}
|
||||||
|
label="Members"
|
||||||
|
icon={UserPlus}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/members`)}
|
||||||
|
/>
|
||||||
<Item
|
<Item
|
||||||
href={`/trees/${treeId}/recovery`}
|
href={`/trees/${treeId}/recovery`}
|
||||||
label="Recovery"
|
label="Recovery"
|
||||||
|
|||||||
Vendored
+207
@@ -925,6 +925,42 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1284,6 +1320,45 @@ export interface components {
|
|||||||
/** Source Id */
|
/** Source Id */
|
||||||
source_id?: string | null;
|
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 */
|
||||||
NameApply: {
|
NameApply: {
|
||||||
/** Edits */
|
/** 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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"components": {
|
||||||
@@ -4737,6 +4942,91 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "MediaUpdate"
|
"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": {
|
"NameApply": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"edits": {
|
"edits": {
|
||||||
|
|||||||
Reference in New Issue
Block a user