Account menu + Settings (change password); per-tree home person; full-width tree

- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
  Settings and Sign out. New /settings page: account info + change password
  (POST /auth/change-password, re-verifies current password). Export/restore/
  delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
  Read; the tree and family views open focused on it; the person page gets a
  "Set as default" control and "Default person" badge. Cleared if that person
  is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
  column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
  like home_person_id audits cleanly.

50 backend tests pass; migration up/down verified; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:05:04 -04:00
parent a8929c2862
commit 0262ed3d97
19 changed files with 521 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from "@/components/app-shell";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function SettingsPage() {
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
async function changePassword(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (next.length < 8) {
setMsg({ kind: "err", text: "New password must be at least 8 characters." });
return;
}
if (next !== confirm) {
setMsg({ kind: "err", text: "New passwords don't match." });
return;
}
setBusy(true);
const { error } = await api.POST("/api/v1/auth/change-password", {
body: { current_password: current, new_password: next },
});
setBusy(false);
if (error) {
setMsg({ kind: "err", text: "Current password is incorrect." });
return;
}
setCurrent("");
setNext("");
setConfirm("");
setMsg({ kind: "ok", text: "Password changed." });
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div>
<span className="text-[var(--muted)]">Name: </span>
{me?.display_name ?? "—"}
</div>
<div>
<span className="text-[var(--muted)]">Email: </span>
{me?.email ?? "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Change password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={changePassword} className="flex max-w-sm flex-col gap-3">
<Input
type="password"
placeholder="Current password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Input
type="password"
placeholder="New password (min 8 chars)"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
<Input
type="password"
placeholder="Confirm new password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{msg && (
<p className={msg.kind === "ok" ? "text-sm text-bronze" : "text-sm text-red-600"}>
{msg.text}
</p>
)}
<Button type="submit" disabled={busy || !current || !next}>
{busy ? "Saving…" : "Change password"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Your data</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted)]">
Full-account export, restore, and account deletion are coming next.
</p>
</CardContent>
</Card>
</div>
);
}
+5 -2
View File
@@ -50,15 +50,18 @@ export default function FamilyViewPage() {
router.push("/login");
return;
}
const [r, e] = await Promise.all([
const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setReady(true);
}, [router, treeId]);
@@ -108,6 +108,7 @@ export default function PersonDetailPage() {
const [people, setPeople] = useState<Person[]>([]);
const [names, setNames] = useState<Name[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
@@ -168,12 +169,13 @@ export default function PersonDetailPage() {
return;
}
setPerson(p.data ?? null);
const [all, nm, mine, ev, rl, src, cit] = await Promise.all([
const [all, nm, mine, tr, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/users/me"),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
@@ -186,6 +188,7 @@ export default function PersonDetailPage() {
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setTree(tr.data ?? null);
setEvents(ev.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
@@ -387,6 +390,14 @@ export default function PersonDetailPage() {
load();
}
async function setDefaultPerson(make: boolean) {
await api.PATCH("/api/v1/trees/{tree_id}", {
params: { path: { tree_id: treeId } },
body: { home_person_id: make ? personId : null },
});
load();
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
@@ -418,6 +429,7 @@ export default function PersonDetailPage() {
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
const isSelf = me?.self_person_id === personId;
const isDefault = tree?.home_person_id === personId;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
@@ -561,15 +573,20 @@ export default function PersonDetailPage() {
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="flex items-center gap-3 text-3xl font-semibold">
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
{person.primary_name ?? "Unnamed person"}
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
</span>
)}
{isDefault && (
<span className="rounded-full border border-bronze/40 px-2.5 py-1 text-xs font-medium text-bronze">
Default person
</span>
)}
</h1>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
{isSelf ? (
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
@@ -580,6 +597,11 @@ export default function PersonDetailPage() {
This is me
</Button>
)}
{!isDefault && (
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
Set as default
</Button>
)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
+6 -2
View File
@@ -50,16 +50,20 @@ export default function TreePage() {
router.push("/login");
return;
}
const [r, e] = await Promise.all([
const [r, e, t] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
]);
if (cancelled) return;
const ppl = p.data ?? [];
const home = t.data?.home_person_id ?? null;
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
// Open on the tree's default/home person when set, else the first person.
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
setStatus(ppl.length ? "ready" : "empty");
})().catch(() => !cancelled && setStatus("error"));
return () => {