"""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, ) from app.rate_limit import limiter from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse from app.utils.errors import AuthError, ConflictError, 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, ) except ConflictError as exc: raise HTTPException(status.HTTP_409_CONFLICT, 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) async def refresh( 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] @router.get("/context", response_model=_UserContext) async def get_context( current_user: CurrentUserDep, pd_repo: PersonalDataRepositoryDep, child_repo: ChildRepositoryDep, ) -> _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_ENTITY, "No parent profile found") children = await child_repo.list_by_workspace(pd.workspace_id) 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 ], ) 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, )