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:
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Admin-only user management: list provisioned users, promote/demote admin,
|
||||
and delete a user together with all of their data.
|
||||
|
||||
New users are normally provisioned just-in-time on first PocketID login
|
||||
(see app/api/auth.py). This router is the in-app surface for managing them.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import (
|
||||
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(current_user: User):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
|
||||
async def _admin_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
has_passkey: bool
|
||||
activity_count: int
|
||||
created_at: Optional[str]
|
||||
|
||||
|
||||
class AdminUpdate(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
# activity counts per user in one grouped query
|
||||
counts = dict(
|
||||
(await db.execute(
|
||||
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
|
||||
)).all()
|
||||
)
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
UserOut(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
is_admin=u.is_admin,
|
||||
has_passkey=u.pocketid_sub is not None,
|
||||
activity_count=counts.get(u.id, 0),
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def set_admin(
|
||||
user_id: int,
|
||||
body: AdminUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot change your own admin status")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Demoting the last remaining admin would lock everyone out.
|
||||
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot demote the last admin")
|
||||
|
||||
user.is_admin = body.is_admin
|
||||
await db.commit()
|
||||
return {"status": "ok", "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot delete your own account")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
if user.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the last admin")
|
||||
|
||||
# Ordered deletes: PersonalRecord and the activity/route child tables have no
|
||||
# cascade path from User, so remove them before the parents to avoid FK errors.
|
||||
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
|
||||
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
|
||||
|
||||
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
|
||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
|
||||
await db.execute(delete(Activity).where(Activity.user_id == user_id))
|
||||
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
|
||||
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
# Remove the user's uploaded files from disk (best-effort).
|
||||
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
|
||||
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user