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
+
+
+
+
+
+
+
+
+ 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",