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:
@@ -6,7 +6,7 @@ import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -40,6 +40,131 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"TimescaleDB hypertable skipped: {e}")
|
||||
|
||||
# Add columns that were introduced after initial table creation (non-fatal)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE garmin_connect_configs "
|
||||
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Column migration skipped: {e}")
|
||||
|
||||
# health_metrics columns added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for stmt in [
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
||||
]:
|
||||
await conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
print(f"health_metrics column migration skipped: {e}")
|
||||
|
||||
# biological_sex column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.biological_sex column migration skipped: {e}")
|
||||
|
||||
# pocketid_allowed_group column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.pocketid_allowed_group column migration skipped: {e}")
|
||||
|
||||
# route_segments auto_generated column added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"route_segments column migration skipped: {e}")
|
||||
|
||||
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
UPDATE health_metrics SET
|
||||
avg_hr_day = sub.avg_hr,
|
||||
max_hr_day = sub.max_hr
|
||||
FROM (
|
||||
SELECT id,
|
||||
AVG((elem->>1)::float) AS avg_hr,
|
||||
MAX((elem->>1)::float) AS max_hr
|
||||
FROM health_metrics,
|
||||
json_array_elements(intraday_hr) AS elem
|
||||
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
|
||||
AND intraday_hr IS NOT NULL
|
||||
AND (elem->>1)::float > 0
|
||||
GROUP BY id
|
||||
) sub
|
||||
WHERE health_metrics.id = sub.id
|
||||
"""))
|
||||
except Exception as e:
|
||||
print(f"avg_hr_day backfill skipped: {e}")
|
||||
|
||||
# Replace the all-columns unique constraint on personal_records with a partial
|
||||
# index (only current records must be unique per user/sport/distance).
|
||||
# The old constraint also covered is_current_record=False rows, causing
|
||||
# UniqueViolation crashes when multiple workers deactivate the same PR.
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE personal_records "
|
||||
"DROP CONSTRAINT IF EXISTS uq_pr_current"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
|
||||
"ON personal_records (user_id, sport_type, distance_m) "
|
||||
"WHERE is_current_record = true"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"PR constraint migration skipped: {e}")
|
||||
|
||||
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
|
||||
# without first manually unlinking every activity.
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities "
|
||||
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE activities "
|
||||
"ADD CONSTRAINT activities_named_route_id_fkey "
|
||||
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"FK migration skipped: {e}")
|
||||
|
||||
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"avg_speed_ms fix skipped: {e}")
|
||||
|
||||
# Seed admin user (only if password is configured)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
@@ -98,6 +223,8 @@ app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
Reference in New Issue
Block a user