Files
SkinsPins/app/routers/auth.py
T
Rolf 8601584099 Add playing handicap, 6-digit login code, and game UX improvements
- Playing handicap: computed at game creation (WHS formula), stored on
  game_players alongside per-hole strokes allocation; displayed on game
  summary with HC card and / markers on scorecard (only when SI defined);
  live game shows +N per player card when SI exists, HC total otherwise;
  new game confirmation previews computed HC per player

- Login: 6-digit code sent alongside magic link in every auth email;
  check_email page has auto-advancing digit inputs with paste support;
  new POST /auth/verify-code route handles code verification

- Game view: Score/Card toggle fixed with position:fixed to always show
  above nav bar on mobile; swipe on score view changes holes, swipe on
  scorecard switches tabs; dirty tracking prevents saving unmodified holes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:37:08 +01:00

157 lines
5.1 KiB
Python

import os
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import aiosqlite
from app.database import get_db
from app.auth import create_magic_token, verify_magic_token, verify_magic_code, create_session, delete_session
from app.templates_config import templates
router = APIRouter()
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
SESSION_COOKIE = "session"
SESSION_TTL_DAYS = 30
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if request.cookies.get(SESSION_COOKIE):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("auth/login.html", {"request": request})
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
email: str = Form(...),
db: aiosqlite.Connection = Depends(get_db),
):
email = email.strip().lower()
# Check email is admin, already registered, or has a pending invitation
if email != ADMIN_EMAIL:
async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as cursor:
existing_user = await cursor.fetchone()
if existing_user is None:
async with db.execute(
"SELECT id FROM invitations WHERE email = ?", (email,)
) as cursor:
invitation = await cursor.fetchone()
if invitation is None:
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": "This email address has not been invited."},
)
await create_magic_token(email, db)
return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email})
@router.post("/auth/verify-code", response_class=HTMLResponse)
async def verify_code(
request: Request,
email: str = Form(...),
code: str = Form(...),
db: aiosqlite.Connection = Depends(get_db),
):
code = code.strip()
verified_email = await verify_magic_code(email, code, db)
if verified_email is None:
return templates.TemplateResponse(
"auth/check_email.html",
{"request": request, "email": email, "error": "Invalid or expired code. Try again."},
status_code=400,
)
async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (verified_email,)) as cursor:
user = await cursor.fetchone()
if user is None:
user_id = secrets.token_urlsafe(16)
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)",
(user_id, verified_email, now),
)
await db.execute("DELETE FROM invitations WHERE email = ?", (verified_email,))
await db.commit()
is_onboarded = False
else:
user_id = user["id"]
is_onboarded = bool(user["is_onboarded"])
session_token = await create_session(user_id, db)
redirect_to = "/" if is_onboarded else "/onboarding"
response = RedirectResponse(redirect_to, status_code=302)
response.set_cookie(
SESSION_COOKIE,
session_token,
max_age=SESSION_TTL_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
@router.get("/auth/verify", response_class=HTMLResponse)
async def auth_verify(
request: Request,
token: str,
db: aiosqlite.Connection = Depends(get_db),
):
email = await verify_magic_token(token, db)
if email is None:
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": "This link is invalid or has expired. Please request a new one."},
)
# Get or create user
async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (email,)) as cursor:
user = await cursor.fetchone()
if user is None:
user_id = secrets.token_urlsafe(16)
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)",
(user_id, email, now),
)
await db.execute("DELETE FROM invitations WHERE email = ?", (email,))
await db.commit()
is_onboarded = False
else:
user_id = user["id"]
is_onboarded = bool(user["is_onboarded"])
session_token = await create_session(user_id, db)
redirect_to = "/" if is_onboarded else "/onboarding"
response = RedirectResponse(redirect_to, status_code=302)
response.set_cookie(
SESSION_COOKIE,
session_token,
max_age=SESSION_TTL_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
@router.get("/logout")
async def logout(request: Request, db: aiosqlite.Connection = Depends(get_db)):
token = request.cookies.get(SESSION_COOKIE)
if token:
await delete_session(token, db)
response = RedirectResponse("/login", status_code=302)
response.delete_cookie(SESSION_COOKIE)
return response