save_pocketid_config cleared the stored client secret whenever the form was
submitted with a blank secret field — but the UI hint says blank means "keep
existing". Re-saving config (e.g. to set the allowed group) therefore wiped the
secret and broke token exchange ("Token exchange failed"). Now a blank field
keeps the existing secret; only a non-empty value overwrites it.
Also log PocketID's actual token-endpoint response body on failure so the cause
(invalid_client, redirect_uri mismatch, etc.) is visible in backend logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the
data layer was already fully user-scoped. This adds the missing pieces for
running real multi-user:
- auth.py callback: link by email to an existing un-linked account (so the
admin keeps their data when first signing in by passkey), collision-safe
username generation, and request the `groups` scope.
- Group gating: optional pocketid_allowed_group (admin-config or
POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the
callback and redirected to /login?auth_error=not_authorized.
- New admin users API (app/api/users.py): list users, promote/demote admin
(guards against demoting/locking out the last admin or yourself), and delete
a user with ordered bulk deletes of all their data + on-disk files.
- ProfilePage: allowed-group field; LoginPage: rejected-login message;
Layout: admin-only Users nav; new UsersPage.
Resync milevault_export to current source (it had drifted many features behind
— missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own
CI) and add POCKETID_ALLOWED_GROUP to .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The maxmet/daily range query returns entries shaped as:
{"generic": {"calendarDate": "...", "vo2MaxPreciseValue": 42.7, ...}, ...}
The extractor was looking at the top level of each entry, finding nothing, and
falling through to the single-point training_status fallback every time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous code used `if not mm_history:` to decide whether to fall back to
get_training_status(). If the maxmet API returned a non-empty list with no valid
vo2max values (or a non-list type), the fallback was skipped and nothing stored.
Changes:
- Normalise mm_raw: only use it if it's a list (handles dict/None responses)
- Check valid_from_range: fall back to training_status whenever no usable value
was found in the range query, regardless of whether it returned entries
- Upgrade all related log lines to INFO so the result is visible without debug mode
- Guard the entry loop against non-dict items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show the most recently known VO2 max value on days where Garmin has
not produced a new estimate (it only updates after certain activities).
Fix the sync lookback days input resetting to the server value during
polling — the form now initialises from the server once on first load
and is not overwritten by background refetches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>