0e18ef2291
save_pocketid_config cleared the stored client secret whenever the form was
submitted with a blank secret field — but the UI hint says blank means "keep
existing". Re-saving config (e.g. to set the allowed group) therefore wiped the
secret and broke token exchange ("Token exchange failed"). Now a blank field
keeps the existing secret; only a non-empty value overwrites it.
Also log PocketID's actual token-endpoint response body on failure so the cause
(invalid_client, redirect_uri mismatch, etc.) is visible in backend logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
8.3 KiB
Python
240 lines
8.3 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, desc
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List
|
||
from datetime import datetime, date, timezone
|
||
|
||
from app.core.database import get_db
|
||
from app.core.security import get_current_user, hash_password, verify_password
|
||
from app.models.user import User, WeightLog
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ── Profile ────────────────────────────────────────────────────────────────
|
||
|
||
class ProfileUpdate(BaseModel):
|
||
max_heart_rate: Optional[int] = None
|
||
resting_heart_rate: Optional[int] = None
|
||
birth_year: Optional[int] = None
|
||
height_cm: Optional[float] = None
|
||
biological_sex: Optional[str] = None
|
||
|
||
|
||
class ProfileOut(BaseModel):
|
||
id: int
|
||
username: str
|
||
email: Optional[str]
|
||
max_heart_rate: Optional[int]
|
||
resting_heart_rate: Optional[int]
|
||
birth_year: Optional[int]
|
||
height_cm: Optional[float]
|
||
biological_sex: Optional[str]
|
||
estimated_max_hr: Optional[int]
|
||
is_admin: bool
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
def _estimated_max_hr(user: User) -> Optional[int]:
|
||
if user.birth_year:
|
||
return 220 - (datetime.now().year - user.birth_year)
|
||
return None
|
||
|
||
|
||
@router.get("/", response_model=ProfileOut)
|
||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||
return {**{c.name: getattr(current_user, c.name)
|
||
for c in User.__table__.columns},
|
||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||
|
||
|
||
@router.patch("/", response_model=ProfileOut)
|
||
async def update_profile(
|
||
body: ProfileUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
old_max_hr = current_user.max_heart_rate
|
||
if body.max_heart_rate is not None:
|
||
if not (100 <= body.max_heart_rate <= 250):
|
||
raise HTTPException(400, "Max HR must be 100–250")
|
||
current_user.max_heart_rate = body.max_heart_rate
|
||
if body.resting_heart_rate is not None:
|
||
if not (20 <= body.resting_heart_rate <= 120):
|
||
raise HTTPException(400, "Resting HR must be 20–120")
|
||
current_user.resting_heart_rate = body.resting_heart_rate
|
||
if body.birth_year is not None:
|
||
if not (1920 <= body.birth_year <= 2010):
|
||
raise HTTPException(400, "Invalid birth year")
|
||
current_user.birth_year = body.birth_year
|
||
if body.height_cm is not None:
|
||
if not (50 <= body.height_cm <= 300):
|
||
raise HTTPException(400, "Height must be 50–300 cm")
|
||
current_user.height_cm = body.height_cm
|
||
if body.biological_sex is not None:
|
||
if body.biological_sex not in ('male', 'female', ''):
|
||
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
|
||
current_user.biological_sex = body.biological_sex or None
|
||
|
||
await db.commit()
|
||
await db.refresh(current_user)
|
||
|
||
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
|
||
from app.workers.tasks import recalculate_hr_zones_for_user
|
||
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
|
||
|
||
return {**{c.name: getattr(current_user, c.name)
|
||
for c in User.__table__.columns},
|
||
"estimated_max_hr": _estimated_max_hr(current_user)}
|
||
|
||
|
||
# ── Password change ────────────────────────────────────────────────────────
|
||
|
||
class PasswordChange(BaseModel):
|
||
current_password: str
|
||
new_password: str
|
||
|
||
|
||
@router.post("/change-password")
|
||
async def change_password(
|
||
body: PasswordChange,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if not current_user.hashed_password:
|
||
raise HTTPException(400, "Account uses passkey login — no password to change")
|
||
if not verify_password(body.current_password, current_user.hashed_password):
|
||
raise HTTPException(400, "Current password is incorrect")
|
||
if len(body.new_password) < 8:
|
||
raise HTTPException(400, "New password must be at least 8 characters")
|
||
current_user.hashed_password = hash_password(body.new_password)
|
||
await db.commit()
|
||
return {"status": "ok"}
|
||
|
||
|
||
# ── PocketID configuration (admin only) ────────────────────────────────────
|
||
|
||
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")
|
||
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||
if not current_user.is_admin:
|
||
raise HTTPException(403, "Admin only")
|
||
from app.core.config import settings
|
||
# 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),
|
||
}
|
||
|
||
|
||
@router.post("/pocketid-config")
|
||
async def save_pocketid_config(
|
||
body: PocketIDConfig,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if not current_user.is_admin:
|
||
raise HTTPException(403, "Admin only")
|
||
if body.issuer is not None:
|
||
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
|
||
if body.client_id is not None:
|
||
current_user.pocketid_client_id = body.client_id or None
|
||
# Only overwrite the secret when a non-empty value is supplied; a blank
|
||
# field means "keep the existing secret" (matches the UI hint).
|
||
if body.client_secret:
|
||
current_user.pocketid_client_secret = body.client_secret
|
||
if body.allowed_group is not None:
|
||
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
|
||
await db.commit()
|
||
return {"status": "ok"}
|
||
|
||
|
||
# ── Weight log ─────────────────────────────────────────────────────────────
|
||
|
||
class WeightEntry(BaseModel):
|
||
date: datetime
|
||
weight_kg: float
|
||
body_fat_pct: Optional[float] = None
|
||
note: Optional[str] = None
|
||
|
||
|
||
class WeightOut(BaseModel):
|
||
id: int
|
||
date: datetime
|
||
weight_kg: float
|
||
body_fat_pct: Optional[float]
|
||
note: Optional[str]
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
@router.get("/weight", response_model=List[WeightOut])
|
||
async def list_weight(
|
||
limit: int = 365,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
result = await db.execute(
|
||
select(WeightLog)
|
||
.where(WeightLog.user_id == current_user.id)
|
||
.order_by(desc(WeightLog.date))
|
||
.limit(limit)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
|
||
@router.post("/weight", response_model=WeightOut)
|
||
async def log_weight(
|
||
body: WeightEntry,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
if not (20 <= body.weight_kg <= 500):
|
||
raise HTTPException(400, "Weight must be 20–500 kg")
|
||
entry = WeightLog(
|
||
user_id=current_user.id,
|
||
date=body.date,
|
||
weight_kg=body.weight_kg,
|
||
body_fat_pct=body.body_fat_pct,
|
||
note=body.note,
|
||
)
|
||
db.add(entry)
|
||
await db.commit()
|
||
await db.refresh(entry)
|
||
return entry
|
||
|
||
|
||
@router.delete("/weight/{entry_id}", status_code=204)
|
||
async def delete_weight(
|
||
entry_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
result = await db.execute(
|
||
select(WeightLog).where(
|
||
WeightLog.id == entry_id,
|
||
WeightLog.user_id == current_user.id,
|
||
)
|
||
)
|
||
entry = result.scalar_one_or_none()
|
||
if not entry:
|
||
raise HTTPException(404, "Not found")
|
||
await db.delete(entry)
|
||
await db.commit()
|