Account menu + Settings (change password), per-tree home person, full-width tree #24
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.api.deps import MailerDep, SessionDep, extract_session_token
|
||||
from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChange,
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RegisterRequest,
|
||||
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
|
||||
await auth_service.reset_password(
|
||||
session, raw_token=data.token, new_password=data.new_password
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_password(
|
||||
data: PasswordChange, session: SessionDep, current: CurrentUser
|
||||
) -> None:
|
||||
await auth_service.change_password(
|
||||
session,
|
||||
user=current,
|
||||
current_password=data.current_password,
|
||||
new_password=data.new_password,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
||||
default=TreeVisibility.private,
|
||||
server_default=TreeVisibility.private.value,
|
||||
)
|
||||
# The person a tree opens focused on (its "home"/root person). Cleared if
|
||||
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
|
||||
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey(
|
||||
"persons.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_trees_home_person_id",
|
||||
use_alter=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
||||
|
||||
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class SessionRead(BaseModel):
|
||||
user: UserRead
|
||||
token: str
|
||||
|
||||
@@ -16,6 +16,7 @@ class TreeUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
visibility: TreeVisibility | None = None
|
||||
home_person_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TreeRead(BaseModel):
|
||||
@@ -26,4 +27,5 @@ class TreeRead(BaseModel):
|
||||
description: str | None
|
||||
visibility: TreeVisibility
|
||||
owner_id: uuid.UUID
|
||||
home_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -3,6 +3,7 @@ the change to a User (or the assistant principal acting for a User). Staged on
|
||||
the session; the caller commits as part of its unit of work.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
|
||||
from app.models.enums import AuditActorType
|
||||
|
||||
|
||||
def _json_safe(d: dict | None) -> dict | None:
|
||||
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
|
||||
it lands in the JSON audit column regardless of what the caller passed."""
|
||||
if d is None:
|
||||
return None
|
||||
return json.loads(json.dumps(d, default=str))
|
||||
|
||||
|
||||
def record_audit(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -30,8 +39,8 @@ def record_audit(
|
||||
tree_id=tree_id,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_type=actor_type,
|
||||
before=before,
|
||||
after=after,
|
||||
before=_json_safe(before),
|
||||
after=_json_safe(after),
|
||||
)
|
||||
session.add(entry)
|
||||
return entry
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import generate_token, hash_password, hash_token
|
||||
from app.core.security import generate_token, hash_password, hash_token, verify_password
|
||||
from app.integrations.auth.local import LocalAuthProvider
|
||||
from app.integrations.mailer.base import Mailer
|
||||
from app.models.auth import Session as SessionModel
|
||||
@@ -17,7 +17,7 @@ from app.models.auth import UserToken
|
||||
from app.models.enums import TokenPurpose
|
||||
from app.models.user import User
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Conflict, NotFound
|
||||
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||
|
||||
_local_provider = LocalAuthProvider()
|
||||
|
||||
@@ -178,6 +178,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
|
||||
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
||||
|
||||
|
||||
async def change_password(
|
||||
session: AsyncSession, *, user: User, current_password: str, new_password: str
|
||||
) -> None:
|
||||
"""Change a logged-in user's password after re-verifying the current one.
|
||||
Revokes other sessions so a changed password takes effect everywhere."""
|
||||
if not user.hashed_password or not verify_password(
|
||||
user.hashed_password, current_password
|
||||
):
|
||||
raise Forbidden("current password is incorrect")
|
||||
user.hashed_password = hash_password(new_password)
|
||||
record_audit(
|
||||
session,
|
||||
action="change_password",
|
||||
entity_type="User",
|
||||
entity_id=user.id,
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
||||
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
||||
await session.execute(
|
||||
|
||||
@@ -282,11 +282,14 @@ async def delete_person(
|
||||
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
|
||||
|
||||
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
|
||||
# never fires — clear any account's self-person link to a deleted person.
|
||||
# never fires — clear any links (account self-person, tree home person) to a
|
||||
# deleted person.
|
||||
deleted_ids = [p.id for p in to_delete]
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.self_person_id.in_([p.id for p in to_delete]))
|
||||
.values(self_person_id=None)
|
||||
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
|
||||
)
|
||||
await session.execute(
|
||||
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -70,7 +70,7 @@ async def update_tree(
|
||||
raise NotFound("tree not found")
|
||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||
raise Forbidden("not an editor of this tree")
|
||||
for key in {"name", "description", "visibility"} & changes.keys():
|
||||
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
|
||||
setattr(tree, key, changes[key])
|
||||
record_audit(
|
||||
session,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""tree.home_person_id (per-tree default/home person)
|
||||
|
||||
Revision ID: c7e1a4f2d3b8
|
||||
Revises: b3d5f8a1c920
|
||||
Create Date: 2026-06-07
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c7e1a4f2d3b8"
|
||||
down_revision: str | None = "b3d5f8a1c920"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("trees", sa.Column("home_person_id", sa.Uuid(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_trees_home_person_id",
|
||||
"trees",
|
||||
"persons",
|
||||
["home_person_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_trees_home_person_id", "trees", type_="foreignkey")
|
||||
op.drop_column("trees", "home_person_id")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Change password and per-tree home person."""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
|
||||
async def test_change_password(client):
|
||||
token = await register(client, "cp@example.com", password="password123")
|
||||
h = auth(token)
|
||||
|
||||
# Wrong current password is rejected.
|
||||
bad = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "nope", "new_password": "newpass123"},
|
||||
headers=h,
|
||||
)
|
||||
assert bad.status_code == 403
|
||||
|
||||
ok = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "newpass123"},
|
||||
headers=h,
|
||||
)
|
||||
assert ok.status_code == 204
|
||||
|
||||
# The new password logs in; the old one does not.
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "newpass123"}
|
||||
)
|
||||
).status_code == 200
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "password123"}
|
||||
)
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_tree_home_person(client):
|
||||
h = auth(await register(client, "home@example.com"))
|
||||
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||
pid = (
|
||||
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Root"}, headers=h)
|
||||
).json()["id"]
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/trees/{tid}", json={"home_person_id": pid}, headers=h
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["home_person_id"] == pid
|
||||
|
||||
# Deleting the home person clears the link.
|
||||
await client.delete(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
|
||||
tree = (await client.get(f"/api/v1/trees/{tid}", headers=h)).json()
|
||||
assert tree["home_person_id"] is None
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+59
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user