0e4bc7b444
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>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""
|
|
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"}
|