feat: handle Garmin MFA and activity retries

This commit is contained in:
Bastian Wagner
2026-08-15 16:33:16 +02:00
parent c2f13611b9
commit 6657124983
7 changed files with 221 additions and 3 deletions

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
from app.db.repositories import UserRepository
from app.db.repositories import ActivityRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
@@ -59,6 +59,42 @@ async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
)
@router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse)
async def garmin_mfa(request: Request, user_id: int, csrf_token: str = Form(...), code: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
stripped = code.strip()
if not stripped or len(stripped) > 20:
raise HTTPException(status_code=400, detail="Invalid MFA code")
try:
outcome = await request.app.state.sync_manager.sync_user(user_id, mfa_code=stripped)
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.post("/activities/{activity_id}/retry", response_class=HTMLResponse)
async def retry_activity(request: Request, activity_id: int, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
with request.app.state.session_factory() as session:
activity_repo = ActivityRepository(session)
try:
activity = activity_repo.reset_retryable_failure(activity_id)
except ValueError:
return HTMLResponse("Activity is not retryable", status_code=409)
user_id = activity.user_id
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("/system", response_class=HTMLResponse)
def system_page(request: Request):
require_admin(request)

View File

@@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates
from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import SyncUser
from app.db.repositories import UserRepository
from app.db.repositories import ActivityRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData
@@ -131,12 +131,14 @@ def user_detail(request: Request, user_id: int):
require_admin(request)
with request.app.state.session_factory() as session:
user = _get_user_or_404(UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
return templates.TemplateResponse(
request,
"users/detail.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
},
)

View File

@@ -0,0 +1,5 @@
<form method="post" action="/users/{{ user.id }}/garmin-mfa">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Garmin MFA code: <input type="text" name="code" maxlength="20" required></label>
<button type="submit">Submit code</button>
</form>

View File

@@ -28,4 +28,26 @@
<dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd>
</dl>
{% if user.action_reason == "garmin_mfa_required" %}
<h2>Garmin MFA required</h2>
{% include "fragments/mfa_form.html" %}
{% endif %}
<h2>Activities</h2>
<ul>
{% for activity in activities %}
<li>
{{ activity.activity_name }} &mdash; {{ activity.status.value }}
{% if activity.status.value == "failed" and activity.retryable %}
<form method="post" action="/activities/{{ activity.id }}/retry" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Retry</button>
</form>
{% endif %}
</li>
{% else %}
<li>No pending activities.</li>
{% endfor %}
</ul>
{% endblock %}