Files
provenance/backend/app/api/v1/auth.py
T
justin 0262ed3d97 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>
2026-06-07 11:05:04 -04:00

95 lines
3.3 KiB
Python

from fastapi import APIRouter, HTTPException, Request, Response, status
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,
SessionRead,
TokenRequest,
)
from app.schemas.user import UserRead
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
settings = get_settings()
response.set_cookie(
settings.cookie_name,
token,
max_age=settings.session_ttl_days * 86400,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED)
async def register(
data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response
) -> SessionRead:
user, token, expires_at = await auth_service.register(
session,
mailer,
email=data.email,
password=data.password,
display_name=data.display_name,
)
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/login", response_model=SessionRead)
async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead:
result = await auth_service.login(session, email=data.email, password=data.password)
if result is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
user, token, expires_at = result
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, session: SessionDep, response: Response) -> None:
raw_token = extract_session_token(request)
if raw_token:
await auth_service.logout(session, raw_token=raw_token)
response.delete_cookie(get_settings().cookie_name)
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(data: TokenRequest, session: SessionDep) -> None:
await auth_service.verify_email(session, raw_token=data.token)
@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED)
async def request_password_reset(
data: PasswordResetRequest, session: SessionDep, mailer: MailerDep
) -> dict:
await auth_service.request_password_reset(session, mailer, email=data.email)
return {"status": "accepted"}
@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None:
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,
)