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>
- Add biological_sex field to User model, profile API, and ProfilePage toggle (male/female) — used to select the correct ACSM VO2 max threshold table
- Replace simple VO2 max number in daily snapshot with a colour-coded SVG radial gauge (Very Poor=red, Poor=orange, Fair=green, Good=blue, Excellent=purple) driven by sex- and age-appropriate thresholds
- Shrink Sleep widget to half-width, expand Heart & HRV to half-width; reorganise Heart & HRV internals into a 2×2 grid to reduce vertical height
- Add connectNulls + showDots to VO2 Max trend chart so sparse readings connect with a continuous line
- Add 3Y and 5Y range options to the Trends selector; increase allDays limit to 2000 for full 5yr snapshot navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UploadPage now polls task status every 2s and invalidates activity,
health-summary, and health-metrics queries on completion so new
activities and health data appear without a hard refresh
- Garmin and Strava export endpoints now return a task_id for polling
- Updating max HR in Profile triggers a background Celery task to
recalculate hr_zones for all existing activities; profile page shows
a confirmation note when this is queued
- Add CLAUDE.md with repo architecture and dev commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>