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 () => {
+14 -1
View File
@@ -2,6 +2,7 @@
import { Menu } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
@@ -12,6 +13,10 @@ import { AppSidebar } from "@/components/app-sidebar";
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// The tree visualization wants the whole canvas; everything else reads better
// in a centered, max-width column.
const fullWidth = /^\/trees\/[^/]+\/tree$/.test(pathname);
return (
<div className="flex min-h-screen">
@@ -49,7 +54,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
)}
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
<div
className={
fullWidth
? "w-full px-4 py-6 md:px-6"
: "mx-auto w-full max-w-4xl px-6 py-10 md:px-10"
}
>
{children}
</div>
</div>
</div>
);
+52 -8
View File
@@ -8,11 +8,12 @@ import {
Image as ImageIcon,
LogOut,
Network,
Settings,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
@@ -23,6 +24,9 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
const [treeName, setTreeName] = useState<string | null>(null);
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!treeId) {
@@ -34,6 +38,18 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
.then((r) => setTreeName(r.data?.name ?? null));
}, [treeId]);
useEffect(() => {
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
}, []);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
async function logout() {
onNavigate?.();
await api.POST("/api/v1/auth/logout");
@@ -120,13 +136,41 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
</div>
)}
<button
onClick={logout}
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
<div ref={menuRef} className="relative mt-auto">
{menuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<Link
href="/settings"
onClick={() => {
setMenuOpen(false);
onNavigate?.();
}}
className="flex items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
>
<Settings className="h-4 w-4 shrink-0" />
Settings
</Link>
<button
onClick={logout}
className="flex w-full items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-bronze"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</div>
)}
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-[var(--foreground)] transition-colors hover:bg-bronze/[0.07]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-bronze/15 text-xs font-semibold uppercase text-bronze">
{(me?.display_name || me?.email || "?").slice(0, 1)}
</span>
<span className="min-w-0 flex-1 truncate">
{me?.display_name || me?.email || "Account"}
</span>
</button>
</div>
</nav>
);
}
+59
View File
@@ -140,6 +140,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/change-password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Change Password */
post: operations["change_password_api_v1_auth_change_password_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/users/me": {
parameters: {
query?: never;
@@ -998,6 +1015,13 @@ export interface components {
* @enum {string}
*/
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
/** PasswordChange */
PasswordChange: {
/** Current Password */
current_password: string;
/** New Password */
new_password: string;
};
/** PasswordResetConfirm */
PasswordResetConfirm: {
/** Token */
@@ -1253,6 +1277,8 @@ export interface components {
* Format: uuid
*/
owner_id: string;
/** Home Person Id */
home_person_id?: string | null;
/**
* Created At
* Format: date-time
@@ -1266,6 +1292,8 @@ export interface components {
/** Description */
description?: string | null;
visibility?: components["schemas"]["TreeVisibility"] | null;
/** Home Person Id */
home_person_id?: string | null;
};
/**
* TreeVisibility
@@ -1545,6 +1573,37 @@ export interface operations {
};
};
};
change_password_api_v1_auth_change_password_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PasswordChange"];
};
};
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"];
};
};
};
};
read_me_api_v1_users_me_get: {
parameters: {
query?: never;
+77
View File
@@ -259,6 +259,40 @@
}
}
},
"/api/v1/auth/change-password": {
"post": {
"tags": [
"auth"
],
"summary": "Change Password",
"operationId": "change_password_api_v1_auth_change_password_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordChange"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/users/me": {
"get": {
"tags": [
@@ -3890,6 +3924,25 @@
"title": "ParentChildQualifier",
"description": "Qualifies a parent_child edge so adoption/donor/blended families are\nfirst-class rather than edge cases (ARCHITECTURE \u00a75)."
},
"PasswordChange": {
"properties": {
"current_password": {
"type": "string",
"title": "Current Password"
},
"new_password": {
"type": "string",
"minLength": 8,
"title": "New Password"
}
},
"type": "object",
"required": [
"current_password",
"new_password"
],
"title": "PasswordChange"
},
"PasswordResetConfirm": {
"properties": {
"token": {
@@ -4702,6 +4755,18 @@
"format": "uuid",
"title": "Owner Id"
},
"home_person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Home Person Id"
},
"created_at": {
"type": "string",
"format": "date-time",
@@ -4752,6 +4817,18 @@
"type": "null"
}
]
},
"home_person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Home Person Id"
}
},
"type": "object",