diff --git a/app/db/repositories.py b/app/db/repositories.py
index ac95348..4710a0b 100644
--- a/app/db/repositories.py
+++ b/app/db/repositories.py
@@ -168,6 +168,15 @@ class ActivityRepository:
self.session.commit()
return activity
+ def reset_retryable_failure(self, activity_id: int) -> Activity:
+ activity = self._require(activity_id)
+ if activity.status != ActivityStatus.FAILED or not activity.retryable:
+ raise ValueError("activity is not retryable")
+ activity.status = activity.last_completed_stage
+ activity.last_error = None
+ self.session.commit()
+ return activity
+
def list_pending_for_user(self, user_id: int) -> list[Activity]:
return list(
self.session.scalars(
diff --git a/app/sync/manager.py b/app/sync/manager.py
index 78b37fc..1551bb7 100644
--- a/app/sync/manager.py
+++ b/app/sync/manager.py
@@ -254,6 +254,11 @@ class SyncManager:
session.commit()
+ if not stop_user_run:
+ user.action_reason = None
+ user.health_state = HealthState.DEGRADED if failed_count > 0 else HealthState.HEALTHY
+ session.commit()
+
status = (
SyncRunStatus.SUCCESS
if failed_count == 0 and not stop_user_run
diff --git a/app/web/operations.py b/app/web/operations.py
index 9e03e16..c4433a4 100644
--- a/app/web/operations.py
+++ b/app/web/operations.py
@@ -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)
diff --git a/app/web/routes.py b/app/web/routes.py
index 1edd7af..0beef9a 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -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,
},
)
diff --git a/app/web/templates/fragments/mfa_form.html b/app/web/templates/fragments/mfa_form.html
new file mode 100644
index 0000000..fd96e0f
--- /dev/null
+++ b/app/web/templates/fragments/mfa_form.html
@@ -0,0 +1,5 @@
+
diff --git a/app/web/templates/users/detail.html b/app/web/templates/users/detail.html
index 3ebce2f..8214e2d 100644
--- a/app/web/templates/users/detail.html
+++ b/app/web/templates/users/detail.html
@@ -28,4 +28,26 @@
Updated at
{{ user.updated_at }}
+
+{% if user.action_reason == "garmin_mfa_required" %}
+Garmin MFA required
+{% include "fragments/mfa_form.html" %}
+{% endif %}
+
+Activities
+
+ {% for activity in activities %}
+ -
+ {{ activity.activity_name }} — {{ activity.status.value }}
+ {% if activity.status.value == "failed" and activity.retryable %}
+
+ {% endif %}
+
+ {% else %}
+ - No pending activities.
+ {% endfor %}
+
{% endblock %}
diff --git a/tests/web/test_mfa.py b/tests/web/test_mfa.py
new file mode 100644
index 0000000..070b7c8
--- /dev/null
+++ b/tests/web/test_mfa.py
@@ -0,0 +1,139 @@
+import logging
+
+from sqlalchemy import text
+
+from app.db.models import Activity, ActivityStatus
+
+
+def test_mfa_code_is_used_once_and_not_persisted(authenticated_client, fake_sync_manager, app, caplog) -> None:
+ with caplog.at_level(logging.DEBUG):
+ response = authenticated_client.post(
+ "/users/1/garmin-mfa",
+ data={"csrf_token": authenticated_client.csrf_token, "code": "123456"},
+ )
+ assert response.status_code == 200
+ assert fake_sync_manager.mfa_calls == [(1, "123456")]
+
+ # Defense-in-depth: the MFA code must never end up written to the real
+ # app database (the one authenticated_client's requests actually hit),
+ # not some unrelated in-memory db.
+ with app.state.session_factory() as session:
+ persisted_text = " ".join(str(row) for row in session.execute(text("select * from sync_runs")).all())
+ assert "123456" not in persisted_text
+
+ # The check that actually matters: the code must never be logged,
+ # regardless of which SyncManager implementation is in play.
+ assert "123456" not in caplog.text
+
+
+def test_mfa_code_rejects_empty_code(authenticated_client, fake_sync_manager) -> None:
+ response = authenticated_client.post(
+ "/users/1/garmin-mfa",
+ data={"csrf_token": authenticated_client.csrf_token, "code": " "},
+ )
+ assert response.status_code == 400
+ assert fake_sync_manager.mfa_calls == []
+
+
+def test_mfa_code_rejects_overlong_code(authenticated_client, fake_sync_manager) -> None:
+ response = authenticated_client.post(
+ "/users/1/garmin-mfa",
+ data={"csrf_token": authenticated_client.csrf_token, "code": "1" * 21},
+ )
+ assert response.status_code == 400
+ assert fake_sync_manager.mfa_calls == []
+
+
+def _seed_activity(app, *, status: ActivityStatus, last_completed_stage: ActivityStatus, retryable: bool, last_error: str | None = None) -> tuple[int, int]:
+ """Seed a real Activity (and its owning user) in the app fixture's actual
+ database -- the same database authenticated_client's HTTP requests hit --
+ and return (user_id, activity_id)."""
+ from app.db.repositories import ActivityRepository, UserRepository
+
+ with app.state.session_factory() as session:
+ user = UserRepository(session).create(
+ name="MFA Test User",
+ enabled=True,
+ mywhoosh_email_enc="mw@example.com",
+ mywhoosh_password_enc="mw-pass",
+ garmin_email_enc="garmin@example.com",
+ garmin_password_enc="garmin-pass",
+ )
+ activity_repo = ActivityRepository(session)
+ activity, _ = activity_repo.get_or_create_discovered(
+ user_id=user.id,
+ mywhoosh_activity_id="mw-activity-1",
+ activity_name="Test Activity",
+ activity_timestamp=None,
+ )
+ activity.status = status
+ activity.last_completed_stage = last_completed_stage
+ activity.retryable = retryable
+ if last_error is not None:
+ activity.last_error = last_error
+ session.commit()
+ return user.id, activity.id
+
+
+def test_retry_resets_and_calls_sync_for_retryable_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
+ user_id, activity_id = _seed_activity(
+ app,
+ status=ActivityStatus.FAILED,
+ last_completed_stage=ActivityStatus.CONVERTED,
+ retryable=True,
+ last_error="some transient error",
+ )
+
+ response = authenticated_client.post(
+ f"/activities/{activity_id}/retry",
+ data={"csrf_token": authenticated_client.csrf_token},
+ )
+
+ assert response.status_code == 200
+ assert fake_sync_manager.user_calls == [user_id]
+
+ with app.state.session_factory() as session:
+ reloaded = session.get(Activity, activity_id)
+ assert reloaded.status == ActivityStatus.CONVERTED
+ assert reloaded.last_error is None
+
+
+def test_retry_rejects_non_retryable_activity(authenticated_client, fake_sync_manager, app) -> None:
+ user_id, activity_id = _seed_activity(
+ app,
+ status=ActivityStatus.FAILED,
+ last_completed_stage=ActivityStatus.CONVERTED,
+ retryable=False,
+ last_error="permanent failure",
+ )
+
+ response = authenticated_client.post(
+ f"/activities/{activity_id}/retry",
+ data={"csrf_token": authenticated_client.csrf_token},
+ )
+
+ assert response.status_code == 409
+ assert fake_sync_manager.user_calls == []
+
+ with app.state.session_factory() as session:
+ reloaded = session.get(Activity, activity_id)
+ assert reloaded.status == ActivityStatus.FAILED
+ assert reloaded.retryable is False
+ assert reloaded.last_error == "permanent failure"
+
+
+def test_retry_rejects_non_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
+ user_id, activity_id = _seed_activity(
+ app,
+ status=ActivityStatus.DISCOVERED,
+ last_completed_stage=ActivityStatus.DISCOVERED,
+ retryable=True,
+ )
+
+ response = authenticated_client.post(
+ f"/activities/{activity_id}/retry",
+ data={"csrf_token": authenticated_client.csrf_token},
+ )
+
+ assert response.status_code == 409
+ assert fake_sync_manager.user_calls == []