from fastapi import APIRouter, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.account import authenticate_self_service from app.auth.admin import require_self_service from app.auth.csrf import ensure_csrf_token, validate_csrf from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository from app.security.credentials import CredentialCipher from app.sync.manager import SyncAlreadyRunning from app.web.operations import _normalize_outcome from app.web.routes import templates router = APIRouter() def _cipher(request: Request) -> CredentialCipher: return CredentialCipher(request.app.state.settings.credential_encryption_key) def _require_non_empty(value: str, field_name: str) -> None: if not value.strip(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must not be empty", ) @router.get("/account-login", response_class=HTMLResponse) def account_login_page(request: Request): return templates.TemplateResponse(request, "account_login.html", {"csrf_token": ensure_csrf_token(request)}) @router.post("/account-login") def account_login( request: Request, csrf_token: str = Form(...), email: str = Form(...), password: str = Form(...), ): validate_csrf(request, csrf_token) cipher = _cipher(request) with request.app.state.session_factory() as session: user = authenticate_self_service(session, cipher, email, password) if user is None: return templates.TemplateResponse( request, "account_login.html", {"csrf_token": ensure_csrf_token(request), "error": "Invalid email or password"}, status_code=401, ) request.session["self_service_user_id"] = user.id return RedirectResponse("/account", status_code=303) @router.post("/account-logout") def account_logout(request: Request, csrf_token: str = Form(...)): validate_csrf(request, csrf_token) request.session.pop("self_service_user_id", None) return RedirectResponse("/account-login", status_code=303) def _get_own_user_or_reauth(request: Request, repository: UserRepository, user_id: int): user = repository.get(user_id) if user is None: request.session.pop("self_service_user_id", None) raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"}) return user @router.get("/account", response_class=HTMLResponse) def account_detail(request: Request): user_id = require_self_service(request) with request.app.state.session_factory() as session: user = _get_own_user_or_reauth(request, UserRepository(session), user_id) activities = ActivityRepository(session).list_pending_for_user(user_id) recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10) return templates.TemplateResponse( request, "account/detail.html", { "csrf_token": ensure_csrf_token(request), "user": user, "activities": activities, "recent_runs": recent_runs, }, ) @router.post("/account/sync", response_class=HTMLResponse) async def account_sync(request: Request, csrf_token: str = Form(...)): user_id = require_self_service(request) validate_csrf(request, csrf_token) try: outcome = await request.app.state.sync_manager.sync_user(user_id) except SyncAlreadyRunning: return HTMLResponse("Sync already running for this user", status_code=409) return templates.TemplateResponse( request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]} ) @router.get("/account/edit", response_class=HTMLResponse) def account_edit_page(request: Request): user_id = require_self_service(request) cipher = _cipher(request) with request.app.state.session_factory() as session: user = _get_own_user_or_reauth(request, UserRepository(session), user_id) return templates.TemplateResponse( request, "account/form.html", { "csrf_token": ensure_csrf_token(request), "user": user, "mywhoosh_email": cipher.decrypt(user.mywhoosh_email_enc), "garmin_email": cipher.decrypt(user.garmin_email_enc), }, ) @router.post("/account/edit") def account_update( request: Request, csrf_token: str = Form(...), mywhoosh_email: str = Form(""), mywhoosh_password: str = Form(""), garmin_email: str = Form(""), garmin_password: str = Form(""), notify_email_enabled: str | None = Form(None), notification_email: str = Form(""), ): user_id = require_self_service(request) validate_csrf(request, csrf_token) _require_non_empty(mywhoosh_email, "mywhoosh_email") _require_non_empty(garmin_email, "garmin_email") notify_enabled = notify_email_enabled is not None if notify_enabled: _require_non_empty(notification_email, "notification_email") cipher = _cipher(request) with request.app.state.session_factory() as session: repository = UserRepository(session) user = _get_own_user_or_reauth(request, repository, user_id) values = { "mywhoosh_email_enc": cipher.encrypt(mywhoosh_email.strip()), "garmin_email_enc": cipher.encrypt(garmin_email.strip()), "notify_email_enabled": notify_enabled, "notification_email": notification_email.strip() or None, } if mywhoosh_password: values["mywhoosh_password_enc"] = cipher.encrypt(mywhoosh_password) if garmin_password: values["garmin_password_enc"] = cipher.encrypt(garmin_password) repository.update(user, **values) return RedirectResponse("/account", status_code=303)