feat: replace admin auth with cookie-based profile picker

Remove all authentication (login, sessions, bcrypt, itsdangerous) since
the app runs on a private homelab LAN. Replace with a profile picker
landing page and cookie-based profile selection (1-year expiry).

- Add Alembic migration to drop password_hash/is_admin columns
- Delete auth service, auth routes, login template, and auth tests
- Rewrite app/utils/auth.py with NoProfileSelectedError and
  require_active_profile dependency
- Add profile creation flow (GET/POST /profiles/create)
- Rewrite home page as profile picker with card layout
- Update all route files to use profile dependency instead of admin auth
- Remove bcrypt and itsdangerous from requirements
- Remove admin_username/admin_password from config
- Update all tests for new profile-based access model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:40:54 -05:00
parent 3dc0171639
commit 576d3bbb68
44 changed files with 523 additions and 1024 deletions

View File

@@ -1,99 +0,0 @@
"""Authentication routes for admin login and logout.
Handles the login form, credential verification, session cookie
management, and logout.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.services.auth_service import AuthService
from app.utils.auth import SESSION_COOKIE_NAME
logger = structlog.get_logger(__name__)
router = APIRouter(tags=["auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Render the login form.
Args:
request: The incoming HTTP request.
Returns:
Rendered login page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": None,
})
@router.post("/login")
async def login_submit(
request: Request,
session: Session = Depends(get_db_session),
):
"""Process login form submission.
Verifies credentials and sets a session cookie on success.
Re-renders the login page with an error on failure.
Args:
request: The incoming HTTP request.
session: Database session.
Returns:
Redirect to home on success, or login page with error.
"""
form = await request.form()
username = form.get("username", "")
password = form.get("password", "")
secret_key = request.app.state.secret_key
auth_service = AuthService(session, secret_key=secret_key)
user = auth_service.authenticate(username, password)
if user is None:
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": "Invalid username or password.",
}, status_code=200)
# Create session token and set cookie — httponly and samesite for security
token = auth_service.create_session_token(user_id=user.id)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
samesite="lax",
max_age=86400, # 24 hours
)
logger.info("login_success", username=username)
return response
@router.get("/logout")
async def logout(request: Request):
"""Clear the session cookie and redirect to login.
Args:
request: The incoming HTTP request.
Returns:
Redirect to login page.
"""
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.delete_cookie(key="active_profile_id")
logger.info("logout")
return response

View File

@@ -15,7 +15,7 @@ from app.models.user import User
from app.services.analytics_service import AnalyticsService
from app.services.exercise_service import ExerciseService
from app.services.progression_service import ProgressionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
@@ -26,25 +26,12 @@ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
async def dashboard(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Render the progress dashboard for the active profile.
Shows: summary stats, volume by day chart, exercise progress links.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
stats = {}
volume_data = {}
if active_profile_id:
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(active_profile_id)
volume_data = analytics.get_volume_by_day(active_profile_id)
"""Render the progress dashboard for the active profile."""
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(profile.id)
volume_data = analytics.get_volume_by_day(profile.id)
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
@@ -55,8 +42,7 @@ async def dashboard(
"stats": stats,
"volume_data_json": json.dumps(volume_data),
"exercises": exercises,
"active_profile": active_profile,
"admin": admin,
"active_profile": profile,
})
@@ -65,31 +51,21 @@ async def exercise_progress(
exercise_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Render per-exercise progress page with charts and suggestions."""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
exercise_service = ExerciseService(session)
exercise = exercise_service.get_exercise_by_id(exercise_id)
progress_data = {}
suggestion = {}
if active_profile_id:
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
active_profile_id, exercise_id,
)
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
profile.id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
active_profile_id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
profile.id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_progress.html", {
@@ -97,6 +73,5 @@ async def exercise_progress(
"exercise": exercise,
"progress_data_json": json.dumps(progress_data),
"suggestion": suggestion,
"active_profile": active_profile,
"admin": admin,
"active_profile": profile,
})

View File

@@ -1,6 +1,6 @@
"""Exercise browser routes with HTMX search/filter support.
All filtering is done via HTMX partial responses no JSON APIs.
All filtering is done via HTMX partial responses -- no JSON APIs.
"""
import structlog
@@ -11,7 +11,7 @@ from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.utils.auth import get_current_admin_user
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
@@ -22,18 +22,9 @@ router = APIRouter(prefix="/exercises", tags=["exercises"])
async def exercise_browser(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Render the exercise browser page with all exercises.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered exercise browser page.
"""
"""Render the exercise browser page with all exercises."""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
workout_days = exercise_service.list_workout_days()
@@ -47,7 +38,6 @@ async def exercise_browser(
"exercises": exercises,
"workout_days": workout_days,
"muscle_groups": muscle_groups,
"admin": admin,
})
@@ -55,24 +45,11 @@ async def exercise_browser(
async def exercise_search(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
workout_day: str = Query(default="", alias="workout_day"),
muscle_group: str = Query(default="", alias="muscle_group"),
):
"""Return filtered exercise list as an HTMX partial.
Called via hx-get from the exercise browser filter dropdowns.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
workout_day: Filter by workout day name.
muscle_group: Filter by muscle group.
Returns:
Rendered exercise list partial HTML.
"""
"""Return filtered exercise list as an HTMX partial."""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises(
workout_day=workout_day or None,

View File

@@ -13,7 +13,7 @@ from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
@@ -24,31 +24,11 @@ router = APIRouter(prefix="/history", tags=["history"])
async def log_history(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Display log history for the active profile.
Shows a list of past workout sessions, most recent first.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered log history page.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
sessions_list = []
if active_profile_id:
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(user_id=active_profile_id)
"""Display log history for the active profile."""
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(user_id=profile.id)
# Resolve workout day names for display
exercise_service = ExerciseService(session)
@@ -59,8 +39,7 @@ async def log_history(
"request": request,
"sessions": sessions_list,
"days_by_id": days_by_id,
"active_profile": active_profile,
"admin": admin,
"active_profile": profile,
})
@@ -69,19 +48,9 @@ async def session_detail(
session_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Display detailed logs for a specific workout session.
Args:
session_id: The workout session ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered session detail page.
"""
"""Display detailed logs for a specific workout session."""
ws_service = WorkoutSessionService(session)
ws = ws_service.get_session_by_id(session_id)
@@ -109,5 +78,4 @@ async def session_detail(
"logs_by_exercise": logs_by_exercise,
"exercises_by_id": exercises_by_id,
"days_by_id": days_by_id,
"admin": admin,
})

View File

@@ -15,7 +15,7 @@ from app.database import get_db_session
from app.models.user import User
from app.services.log_service import LogService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
@@ -26,20 +26,12 @@ router = APIRouter(prefix="/log", tags=["logging"])
async def log_set(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Log a single set for an exercise.
Creates the workout session if it doesn't exist yet (auto-create).
Returns the updated log entries partial for this exercise.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered log entries partial for this exercise.
"""
form = await request.form()
exercise_id = int(form.get("exercise_id", 0))
@@ -49,18 +41,10 @@ async def log_set(
weight = form.get("weight", "")
felt_easy = form.get("felt_easy") == "on"
active_profile_id = get_active_profile_id(request)
if not active_profile_id:
templates = request.app.state.templates
return templates.TemplateResponse("partials/flash_message.html", {
"request": request,
"flash_error": "No profile selected. Switch profiles first.",
})
# Get or create today's session
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=active_profile_id,
user_id=profile.id,
workout_day_id=workout_day_id,
session_date=date.today(),
)
@@ -96,19 +80,9 @@ async def edit_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Edit an existing log entry.
Args:
log_id: The log entry ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered updated log entry partial.
"""
"""Edit an existing log entry."""
form = await request.form()
log_service = LogService(session)
@@ -140,19 +114,9 @@ async def delete_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Delete a log entry.
Args:
log_id: The log entry ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered updated log entries partial.
"""
"""Delete a log entry."""
log_service = LogService(session)
log = log_service.get_log_by_id(log_id)

View File

@@ -3,21 +3,27 @@
Renders Jinja2 templates for user-facing pages.
"""
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.services.user_service import UserService
router = APIRouter(tags=["pages"])
@router.get("/")
async def home_page(request: Request) -> HTMLResponse:
"""Render the home page.
async def home_page(
request: Request,
session: Session = Depends(get_db_session),
) -> HTMLResponse:
"""Render the home page with profile picker or create-profile link."""
user_service = UserService(session)
profiles = user_service.list_users()
Args:
request: The incoming HTTP request.
Returns:
Rendered home page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse(request, "pages/home.html")
return templates.TemplateResponse("pages/home.html", {
"request": request,
"profiles": profiles,
})

View File

@@ -1,6 +1,6 @@
"""Profile management routes.
Admin can view, create, edit user profiles and switch the active profile.
Users can view, create, edit profiles and switch the active profile.
"""
import structlog
@@ -11,7 +11,7 @@ from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
@@ -22,20 +22,11 @@ router = APIRouter(prefix="/profiles", tags=["profiles"])
async def list_profiles(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""List all user profiles for the admin.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile list page.
"""
"""List all user profiles."""
user_service = UserService(session)
profiles = user_service.list_users(exclude_admin=True)
profiles = user_service.list_users()
active_profile_id = get_active_profile_id(request)
templates = request.app.state.templates
@@ -43,7 +34,6 @@ async def list_profiles(
"request": request,
"profiles": profiles,
"active_profile_id": active_profile_id,
"admin": admin,
})
@@ -51,63 +41,101 @@ async def list_profiles(
async def switch_profile(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Switch the active user profile.
Sets a cookie with the selected profile ID.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to the referring page with profile cookie set.
Sets a cookie with the selected profile ID (1 year expiry).
"""
form = await request.form()
profile_id = form.get("profile_id", "")
# Validate profile exists and is not an admin
user_service = UserService(session)
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
referer = request.headers.get("referer", "/")
response = RedirectResponse(url=referer, status_code=303)
response = RedirectResponse(url="/workouts", status_code=303)
if profile and not profile.is_admin:
if profile:
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=86400,
max_age=31536000, # 1 year
)
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
else:
logger.warning("profile_switch_failed", profile_id=profile_id)
response = RedirectResponse(url="/", status_code=303)
return response
@router.get("/create", response_class=HTMLResponse)
async def create_profile_form(request: Request):
"""Render the create-profile form."""
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
})
@router.post("/create")
async def create_profile(
request: Request,
session: Session = Depends(get_db_session),
):
"""Create a new profile, set cookie, redirect to workouts."""
form = await request.form()
display_name = form.get("display_name", "").strip()
if not display_name:
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
"error": "Display name is required.",
})
user_service = UserService(session)
# Generate username from display name
username = display_name.lower().replace(" ", "_")
# Ensure uniqueness
existing = user_service.get_user_by_username(username)
if existing:
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
"error": "A profile with that name already exists.",
})
profile = user_service.create_user(
username=username,
display_name=display_name,
height=form.get("height", "").strip() or None,
weight=form.get("weight", "").strip() or None,
goals=form.get("goals", "").strip() or None,
)
response = RedirectResponse(url="/workouts", status_code=303)
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=31536000, # 1 year
)
logger.info("profile_created", profile_id=profile.id, name=profile.display_name)
return response
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_page(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
active_profile: User = Depends(require_active_profile),
):
"""Render the profile edit form.
Args:
profile_id: The profile ID to edit.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile edit page.
"""
"""Render the profile edit form."""
user_service = UserService(session)
profile = user_service.get_user_by_id(profile_id)
@@ -115,7 +143,6 @@ async def edit_profile_page(
return templates.TemplateResponse("pages/profile_edit.html", {
"request": request,
"profile": profile,
"admin": admin,
})
@@ -124,19 +151,9 @@ async def update_profile(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
active_profile: User = Depends(require_active_profile),
):
"""Process profile edit form submission.
Args:
profile_id: The profile ID to update.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to profiles page.
"""
"""Process profile edit form submission."""
form = await request.form()
user_service = UserService(session)

View File

@@ -14,7 +14,7 @@ from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
@@ -25,20 +25,13 @@ router = APIRouter(prefix="/schedule", tags=["schedule"])
async def schedule_view(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Render the 4-week schedule calendar.
Shows a 4-week grid where each training day is mapped to a
calendar date. Days with completed sessions are highlighted.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
exercise_service = ExerciseService(session)
workout_days = exercise_service.list_workout_days()
@@ -50,12 +43,11 @@ async def schedule_view(
completed_dates = set()
# Get completed sessions for highlighting
if active_profile_id:
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(
user_id=active_profile_id, limit=100,
)
completed_dates = {ws.date for ws in sessions_list}
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(
user_id=profile.id, limit=100,
)
completed_dates = {ws.date for ws in sessions_list}
# 4 workout days per week, 4 weeks
for week_num in range(4):
@@ -81,6 +73,5 @@ async def schedule_view(
return templates.TemplateResponse("pages/schedule.html", {
"request": request,
"weeks": weeks,
"active_profile": active_profile,
"admin": admin,
"active_profile": profile,
})

View File

@@ -18,7 +18,7 @@ from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
@@ -29,18 +29,9 @@ router = APIRouter(prefix="/workouts", tags=["workouts"])
async def workout_days_list(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""List all workout days as clickable cards.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout days list page.
"""
"""List all workout days as clickable cards."""
exercise_service = ExerciseService(session)
days = exercise_service.list_workout_days()
@@ -48,7 +39,6 @@ async def workout_days_list(
return templates.TemplateResponse("pages/workout_days.html", {
"request": request,
"days": days,
"admin": admin,
})
@@ -57,19 +47,9 @@ async def workout_day_detail(
day_name: str,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
profile: User = Depends(require_active_profile),
):
"""Display a full workout day -- warmups + exercises with form cues.
Args:
day_name: The workout day name (e.g., "push", "pull").
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout day detail page.
"""
"""Display a full workout day -- warmups + exercises with form cues."""
exercise_service = ExerciseService(session)
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
@@ -78,19 +58,15 @@ async def workout_day_detail(
warmups = exercise_service.list_warmups()
exercises = exercise_service.list_exercises(workout_day=day_display)
# Get active profile's programming if set
active_profile_id = get_active_profile_id(request)
# Get active profile's programming
active_profile_id = profile.id
programs = {}
active_profile = None
existing_logs = {}
if active_profile_id:
active_profile = session.get(User, active_profile_id)
if active_profile:
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == active_profile_id
)
for prog in session.exec(statement).all():
programs[prog.exercise_id] = prog
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == active_profile_id
)
for prog in session.exec(statement).all():
programs[prog.exercise_id] = prog
# Look up the workout day ID for logging forms
days = exercise_service.list_workout_days()
@@ -102,15 +78,14 @@ async def workout_day_detail(
# Get progression suggestions for each exercise
suggestions = {}
if active_profile_id:
progression = ProgressionService(session)
for exercise in exercises:
suggestions[exercise.id] = progression.get_suggestion(
active_profile_id, exercise.id,
)
progression = ProgressionService(session)
for exercise in exercises:
suggestions[exercise.id] = progression.get_suggestion(
active_profile_id, exercise.id,
)
# Load existing logs for today's session (if any)
if active_profile_id and workout_day_id:
if workout_day_id:
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=active_profile_id,
@@ -129,9 +104,8 @@ async def workout_day_detail(
"warmups": warmups,
"exercises": exercises,
"programs": programs,
"active_profile": active_profile,
"active_profile": profile,
"existing_logs": existing_logs,
"suggestions": suggestions,
"workout_day_id": workout_day_id,
"admin": admin,
})