0262ed3d97
- 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>
95 lines
3.3 KiB
Python
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,
|
|
)
|