eb0350733b
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>
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|