From 0262ed3d975cc39f8ea35510710b012b31b7c292 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 11:05:04 -0400 Subject: [PATCH] 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) --- backend/app/api/v1/auth.py | 15 ++- backend/app/models/tree.py | 10 ++ backend/app/schemas/auth.py | 5 + backend/app/schemas/tree.py | 2 + backend/app/services/audit.py | 13 +- backend/app/services/auth_service.py | 24 +++- backend/app/services/person_service.py | 11 +- backend/app/services/tree_service.py | 2 +- .../versions/c7e1a4f2d3b8_tree_home_person.py | 33 +++++ backend/tests/test_account_settings.py | 53 ++++++++ frontend/app/settings/layout.tsx | 5 + frontend/app/settings/page.tsx | 120 ++++++++++++++++++ frontend/app/trees/[id]/page.tsx | 7 +- .../trees/[id]/persons/[personId]/page.tsx | 28 +++- frontend/app/trees/[id]/tree/page.tsx | 8 +- frontend/components/app-shell.tsx | 15 ++- frontend/components/app-sidebar.tsx | 60 +++++++-- frontend/lib/api/schema.d.ts | 59 +++++++++ frontend/openapi.json | 77 +++++++++++ 19 files changed, 521 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/versions/c7e1a4f2d3b8_tree_home_person.py create mode 100644 backend/tests/test_account_settings.py create mode 100644 frontend/app/settings/layout.tsx create mode 100644 frontend/app/settings/page.tsx diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index fc20c93..e7c68c3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, + ) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 8581d69..55d6a77 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -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): diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f3cb7b0..92afdd6 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 2c52c89..381b160 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -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 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py index 0c87831..5fb9b54 100644 --- a/backend/app/services/audit.py +++ b/backend/app/services/audit.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 57aeaa7..43dcc22 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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( diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index 2482a1c..2645e8c 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -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() diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index bd40824..2a80323 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -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, diff --git a/backend/migrations/versions/c7e1a4f2d3b8_tree_home_person.py b/backend/migrations/versions/c7e1a4f2d3b8_tree_home_person.py new file mode 100644 index 0000000..74b541a --- /dev/null +++ b/backend/migrations/versions/c7e1a4f2d3b8_tree_home_person.py @@ -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") diff --git a/backend/tests/test_account_settings.py b/backend/tests/test_account_settings.py new file mode 100644 index 0000000..bb14199 --- /dev/null +++ b/backend/tests/test_account_settings.py @@ -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 diff --git a/frontend/app/settings/layout.tsx b/frontend/app/settings/layout.tsx new file mode 100644 index 0000000..3e818bd --- /dev/null +++ b/frontend/app/settings/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from "@/components/app-shell"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx new file mode 100644 index 0000000..109eb33 --- /dev/null +++ b/frontend/app/settings/page.tsx @@ -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 ( +
+

Settings

+ + + + Account + + +
+ Name: + {me?.display_name ?? "—"} +
+
+ Email: + {me?.email ?? "—"} +
+
+
+ + + + Change password + + +
+ setCurrent(e.target.value)} + /> + setNext(e.target.value)} + /> + setConfirm(e.target.value)} + /> + {msg && ( +

+ {msg.text} +

+ )} + +
+
+
+ + + + Your data + + +

+ Full-account export, restore, and account deletion are coming next. +

+
+
+
+ ); +} diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index 79ca7b3..6f568a7 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -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]); diff --git a/frontend/app/trees/[id]/persons/[personId]/page.tsx b/frontend/app/trees/[id]/persons/[personId]/page.tsx index 27ca51a..52064a8 100644 --- a/frontend/app/trees/[id]/persons/[personId]/page.tsx +++ b/frontend/app/trees/[id]/persons/[personId]/page.tsx @@ -108,6 +108,7 @@ export default function PersonDetailPage() { const [people, setPeople] = useState([]); const [names, setNames] = useState([]); const [me, setMe] = useState(null); + const [tree, setTree] = useState(null); const [events, setEvents] = useState([]); const [rels, setRels] = useState([]); const [sources, setSources] = useState([]); @@ -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

Not found.

; 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, cites: Citation[]) { @@ -561,15 +573,20 @@ export default function PersonDetailPage() { ) : (
-

+

{person.primary_name ?? "Unnamed person"} {isSelf && ( This is you )} + {isDefault && ( + + Default person + + )}

-
+
{citeControl("p", { person_id: personId }, personCites)} {isSelf ? ( )} + {!isDefault && ( + + )} diff --git a/frontend/app/trees/[id]/tree/page.tsx b/frontend/app/trees/[id]/tree/page.tsx index 957d959..3cdb672 100644 --- a/frontend/app/trees/[id]/tree/page.tsx +++ b/frontend/app/trees/[id]/tree/page.tsx @@ -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 () => { diff --git a/frontend/components/app-shell.tsx b/frontend/components/app-shell.tsx index b803d72..0103473 100644 --- a/frontend/components/app-shell.tsx +++ b/frontend/components/app-shell.tsx @@ -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 (
@@ -49,7 +54,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
)} -
{children}
+
+ {children} +
); diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index fe358e0..07cb9e6 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -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", "", ...] const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null; const [treeName, setTreeName] = useState(null); + const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(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 }) {
)} - +
+ {menuOpen && ( +
+ { + 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 + + +
+ )} + +
); } diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 32486d2..10bd503 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -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; diff --git a/frontend/openapi.json b/frontend/openapi.json index 3b47069..32bb5b6 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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", -- 2.52.0