c51c872c33
- Add Skins game format with optional carryover, mirroring BBB scoring pattern
- Add GolfCourseAPI integration for course search and import (httpx)
- Unwrap {"course": {}} API response envelope on import
- Add searchable course combobox and player filter to new game form
- Redirect to summary page after finishing a game
- Fix auth to allow registered users without a pending invitation
- Fix hardcoded BBB format in create_game; store carryover flag in DB
- Add game summary/scorecard view for completed games
- Add live games social stream to dashboard
- Add course name + subtitle to recent game cards
- Reorder nav: Home → Games → Admin → Profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.5 KiB
Python
109 lines
3.5 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, 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.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
|