Multi-user via PocketID: account linking, group gating, admin user management

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>
This commit is contained in:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
+52 -21
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel, model_validator
from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
@model_validator(mode='after')
def _strip_bb_values(self):
if isinstance(self.body_battery, dict):
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
return self
class Config:
from_attributes = True
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
q = q.where(HealthMetric.date >= from_date)
if to_date:
q = q.where(HealthMetric.date <= to_date)
q = q.order_by(desc(HealthMetric.date)).limit(limit)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@@ -73,8 +83,6 @@ async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
@@ -83,9 +91,7 @@ async def health_summary(
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
@@ -97,7 +103,7 @@ async def health_summary(
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
@@ -116,23 +122,48 @@ async def health_summary(
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
"sleep_stages": metric.sleep_stages if metric else None,
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually add or update a health metric for a given date."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
from fastapi import HTTPException
date_str = body.get("date")
if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
@@ -153,4 +184,4 @@ async def add_manual_metric(
db.add(metric)
await db.commit()
return {"status": "ok"}
return {"status": "ok"}