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
+14 -1
View File
@@ -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,
)
+10
View File
@@ -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):
+5
View File
@@ -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
+2
View File
@@ -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
+11 -2
View File
@@ -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
+22 -2
View File
@@ -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(
+7 -4
View File
@@ -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()
+1 -1
View File
@@ -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,