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