Files
SkinsPins/app/routers/auth.py
T
Rolf 3a1da1482c Add full SkinsPins application
- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring
- Passwordless magic link auth with email (aiosmtplib) and admin invite system
- Real-time score entry via WebSocket with drag-and-drop player ordering
- Course management with per-hole par and stroke index
- Scorecard with golf score markers (birdie, eagle, bogey, etc.)
- Two-step new game form with tee selection per player
- Dashboard with stats, live games social stream, and recent game history
- Game summary view (read-only scorecard with leaderboard)
- User profile pages with win stats
- PWA install support with generated PNG icons
- Admin panel for users, courses, games, and invitations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:16:46 +01:00

106 lines
3.3 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 or invited
if email != ADMIN_EMAIL:
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