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:
@@ -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,
|
||||
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"
|
||||
|
||||
Vendored
+207
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user