Files
F-r-ldraledigplannerare/backend/app/routers/auth.py
T
jacobh2 6d91f153d7 Merge branch 'vacation-rescue-and-wrap'
Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:48:33 +02:00

151 lines
5.3 KiB
Python

"""HTTP routes for auth: register, login, logout, me, refresh."""
from fastapi import APIRouter, Cookie, HTTPException, Response, status
from pydantic import BaseModel
from starlette.requests import Request
from app.config import settings
from app.dependencies import (
AuthServiceDep,
ChildRepositoryDep,
CurrentUserDep,
PersonalDataRepositoryDep,
WorkspaceRepositoryDep,
)
from app.rate_limit import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError
router = APIRouter(prefix="/api/auth", tags=["auth"])
_REFRESH_COOKIE = "refresh_token"
_ACCESS_EXPIRES_IN = settings.jwt_access_token_expire_minutes * 60
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def register(
request: Request, payload: RegisterRequest, response: Response, service: AuthServiceDep
) -> TokenResponse:
try:
user, _pd = await service.register(
email=str(payload.email),
password=payload.password,
display_name=payload.display_name,
invite_token=payload.invite_token,
)
except ConflictError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
except InvitationError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
access = service.create_access_token(user.id)
refresh = service.create_refresh_token(user.id)
_set_refresh_cookie(response, refresh)
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(
request: Request, payload: LoginRequest, response: Response, service: AuthServiceDep
) -> TokenResponse:
try:
user = await service.login(str(payload.email), payload.password)
except AuthError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
access = service.create_access_token(user.id)
refresh = service.create_refresh_token(user.id)
_set_refresh_cookie(response, refresh)
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response) -> None:
response.delete_cookie(_REFRESH_COOKIE)
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("30/minute")
async def refresh(
request: Request,
response: Response,
service: AuthServiceDep,
refresh_token: str | None = Cookie(default=None, alias=_REFRESH_COOKIE),
) -> TokenResponse:
if not refresh_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No refresh token")
try:
access = await service.refresh_access_token(refresh_token)
except UnauthorizedError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.get("/me", response_model=UserMeResponse)
async def me(current_user: CurrentUserDep) -> UserMeResponse:
# PersonalData is fetched separately when workspace context is needed.
# Here we return just the user-level data.
return UserMeResponse(
id=str(current_user.id),
email=current_user.email,
display_name="", # enriched by the frontend via workspace API
workspace_id=None, # client fetches via /api/workspace after login
)
class _ChildContext(BaseModel):
id: str
name: str
birth_date: str
class _UserContext(BaseModel):
personal_data_uuid: str
workspace_id: str
parent_name: str
children: list[_ChildContext]
# Mirrors `WorkspaceResponse.take_pl_on_red_days` — included here so the
# frontend's cold-load `refreshContext()` can build its `workspace`
# state from this response alone, without a follow-up `GET
# /workspace/:id` (see AGENTS.md H12 step 1).
take_pl_on_red_days: bool
@router.get("/context", response_model=_UserContext)
async def get_context(
current_user: CurrentUserDep,
pd_repo: PersonalDataRepositoryDep,
child_repo: ChildRepositoryDep,
ws_repo: WorkspaceRepositoryDep,
) -> _UserContext:
"""Return workspace + children for the current user. Used by the dev harness."""
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
if pd is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No parent profile found")
children = await child_repo.list_by_workspace(pd.workspace_id)
workspace = await ws_repo.get_by_id(pd.workspace_id)
if workspace is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No workspace found")
return _UserContext(
personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id),
parent_name=pd.name,
children=[
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in children
],
take_pl_on_red_days=workspace.take_pl_on_red_days,
)
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_REFRESH_COOKIE,
value=token,
httponly=True,
secure=True,
samesite="lax",
max_age=settings.jwt_refresh_token_expire_days * 86400,
)