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
+11 -1
View File
@@ -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, garmin_sync
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
async def init_db():
@@ -73,6 +73,15 @@ async def init_db():
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:
@@ -215,6 +224,7 @@ 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")