Files
provenance/frontend/app/trees/[id]/members/page.tsx
T
justin eb0350733b 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>
2026-06-09 12:43:30 -04:00

169 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}