diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 43d9fce..4e76906 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession): return issuer, client_id, client_secret +async def _get_allowed_group(db: AsyncSession): + """Group a PocketID user must belong to in order to sign in (None = allow all).""" + result = await db.execute(select(User).where(User.is_admin == True).limit(1)) + admin = result.scalar_one_or_none() + group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group + return (group or "").strip() or None + + +async def _unique_username(db: AsyncSession, base: str) -> str: + """Return `base`, or `base-2`, `base-3`, … until it is not already taken.""" + base = (base or "user").strip() or "user" + candidate = base + n = 1 + while True: + existing = await db.execute(select(User).where(User.username == candidate)) + if existing.scalar_one_or_none() is None: + return candidate + n += 1 + candidate = f"{base}-{n}" + + class Token(BaseModel): access_token: str token_type: str @@ -79,7 +100,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)): "client_id": client_id, "redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback", "response_type": "code", - "scope": "openid profile email", + "scope": "openid profile email groups", } return {"url": f"{issuer}/authorize?{urlencode(params)}"} @@ -106,17 +127,44 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): ) userinfo = userinfo_resp.json() + from fastapi.responses import RedirectResponse + sub = userinfo.get("sub") email = userinfo.get("email") preferred_username = userinfo.get("preferred_username") or email + # Group gating: if an allowed group is configured, the user must be in it. + allowed_group = await _get_allowed_group(db) + if allowed_group: + groups = userinfo.get("groups") or [] + if allowed_group not in groups: + return RedirectResponse(url="/login?auth_error=not_authorized") + + # 1) Existing passkey identity β†’ use it. result = await db.execute(select(User).where(User.pocketid_sub == sub)) user = result.scalar_one_or_none() + + # 2) No passkey identity yet, but an account with this email exists and is + # not already linked to a different passkey β†’ link them (preserves data). + if not user and email: + result = await db.execute(select(User).where(User.email == email)) + existing = result.scalar_one_or_none() + if existing and existing.pocketid_sub is None: + existing.pocketid_sub = sub + user = existing + + # 3) Otherwise provision a new account with a collision-safe username. if not user: - user = User(username=preferred_username, email=email, pocketid_sub=sub) + base = preferred_username or (email.split("@")[0] if email else "user") + username = await _unique_username(db, base) + # Only set email if no other account already claims it (unique column). + email_taken = False + if email: + dup = await db.execute(select(User).where(User.email == email)) + email_taken = dup.scalar_one_or_none() is not None + user = User(username=username, email=None if email_taken else email, pocketid_sub=sub) db.add(user) await db.flush() token = create_access_token({"sub": str(user.id)}) - from fastapi.responses import RedirectResponse return RedirectResponse(url=f"/?token={token}") diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index 7668676..0e307d4 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -121,6 +121,7 @@ class PocketIDConfig(BaseModel): issuer: Optional[str] = None client_id: Optional[str] = None client_secret: Optional[str] = None + allowed_group: Optional[str] = None @router.get("/pocketid-config") @@ -131,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)): # Show DB config if set, fall back to env issuer = current_user.pocketid_issuer or settings.pocketid_issuer client_id = current_user.pocketid_client_id or settings.pocketid_client_id + allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group return { "issuer": issuer or "", "client_id": client_id or "", "client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret), + "allowed_group": allowed_group or "", "enabled": bool(issuer and client_id), } @@ -153,6 +156,8 @@ async def save_pocketid_config( current_user.pocketid_client_id = body.client_id or None if body.client_secret is not None: current_user.pocketid_client_secret = body.client_secret or None + if body.allowed_group is not None: + current_user.pocketid_allowed_group = body.allowed_group.strip() or None await db.commit() return {"status": "ok"} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..a619767 --- /dev/null +++ b/backend/app/api/users.py @@ -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"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 37596be..55b4ec2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER") pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID") pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET") + pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP") # Files file_store_path: str = Field("/data/files", env="FILE_STORE_PATH") # Environment diff --git a/backend/app/main.py b/backend/app/main.py index b6b5174..cf38e87 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bf6c9f0..f0bc014 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -33,6 +33,8 @@ class User(Base): pocketid_issuer = Column(String(512), nullable=True) pocketid_client_id = Column(String(256), nullable=True) pocketid_client_secret = Column(String(256), nullable=True) + # Only PocketID users in this group may sign in. Null/blank = allow all. + pocketid_allowed_group = Column(String(128), nullable=True) activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan") health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 55f1d37..ae2b1e0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import SegmentsPage from './pages/SegmentsPage' import RecordsPage from './pages/RecordsPage' import UploadPage from './pages/UploadPage' import ProfilePage from './pages/ProfilePage' +import UsersPage from './pages/UsersPage' function RequireAuth({ children }) { const token = useAuthStore((s) => s.token) @@ -39,6 +40,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index 93e657b..d5b85af 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -10,6 +10,7 @@ const nav = [ { to: '/records', label: 'Records', icon: 'πŸ†' }, { to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/profile', label: 'Profile', icon: 'βš™οΈ' }, + { to: '/users', label: 'Users', icon: 'πŸ‘₯', adminOnly: true }, ] export default function Layout() { @@ -32,7 +33,7 @@ export default function Layout() {