Settings page tidy-up driven by user feedback that it had grown too busy:
- Each section (Import, Invite, Email digests, Cloud sync) is now a
native <details>/<summary> accordion. Import stays open by default
because /settings#import is the deep-link target from the dashboard
CTA; the others collapse so the page lands quiet.
- Manage subscription is a right-aligned gear-icon button instead of
a rectangular text button — the descriptive copy moves into the
tooltip. Frees up the Tier row of visual weight.
Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.
The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.
Tests rewritten to cover the new welcome-email path:
- first login sends exactly one welcome email
- returning user gets none
- SMTP failure does not break the redirect
- regression guard: returning user who opted out stays opted out
Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""Authentication routes: /login, /verify, /verify/resend, /logout.
|
|
|
|
Cassandra is passwordless. Single auth flow:
|
|
|
|
GET /login → enter email
|
|
POST /login → get_or_create_user → issue OTP → send → 303 /verify
|
|
GET /verify → enter 6-digit code (email shown from pending cookie)
|
|
POST /verify → validate → set session → 303 /
|
|
POST /verify/resend → reissue OTP (rate-limited)
|
|
|
|
Signup and login are intentionally the same path — typing your email is
|
|
sign-in if you've been here before, sign-up otherwise. No UI signal
|
|
distinguishes the two, which also masks user-enumeration.
|
|
|
|
The /signup endpoints from the previous auth scheme are gone. Anything
|
|
that linked to /signup should now link to /login.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import APIRouter, Depends, Form, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import (
|
|
PENDING_COOKIE_NAME,
|
|
PENDING_TTL_SECONDS,
|
|
SESSION_COOKIE_NAME,
|
|
SESSION_TTL_SECONDS,
|
|
sign_pending,
|
|
sign_session,
|
|
verify_pending,
|
|
)
|
|
from app.config import get_settings
|
|
from app.db import get_session, utcnow
|
|
from app.logging import get_logger
|
|
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
|
from app.services import otp_service, referral_service
|
|
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
|
|
from app.templates_env import templates
|
|
|
|
|
|
log = get_logger("auth_router")
|
|
|
|
router = APIRouter(tags=["auth"])
|
|
|
|
|
|
def _safe_next(next_value: str | None) -> str:
|
|
"""Only allow same-origin relative paths to prevent open-redirect."""
|
|
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
|
|
return "/"
|
|
if urlparse(next_value).netloc:
|
|
return "/"
|
|
return next_value
|
|
|
|
|
|
def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=sign_session(user_id),
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=False,
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _set_pending_cookie(
|
|
response: RedirectResponse,
|
|
email: str,
|
|
user_id: int,
|
|
ref: str | None = None,
|
|
) -> None:
|
|
response.set_cookie(
|
|
key=PENDING_COOKIE_NAME,
|
|
value=sign_pending(email, user_id, ref=ref),
|
|
max_age=PENDING_TTL_SECONDS,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=False,
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _clear_pending_cookie(response) -> None:
|
|
response.delete_cookie(PENDING_COOKIE_NAME, path="/")
|
|
|
|
|
|
async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
|
|
"""Generate a code, persist its hash, send the email. Returns True on
|
|
success. Returns False (and logs) if SMTP submission fails — the OTP
|
|
row is still created so the user can hit /verify/resend."""
|
|
code = await otp_service.issue(session, email, purpose="auth")
|
|
try:
|
|
await send_otp(email, code, otp_service.OTP_TTL_MINUTES)
|
|
return True
|
|
except EmailSendError:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Login (email entry)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
async def login_page(
|
|
request: Request,
|
|
next: str | None = None,
|
|
error: str | None = None,
|
|
ref: str | None = None,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
# If a valid referral code is supplied, surface a small "invited"
|
|
# banner. We resolve it server-side so the banner can show the
|
|
# referrer's actual greeting (and a bad code silently degrades).
|
|
ref_norm = referral_service.normalise_code(ref) if ref else None
|
|
referrer = (
|
|
await referral_service.lookup_referrer(session, ref_norm)
|
|
if ref_norm else None
|
|
)
|
|
return templates.TemplateResponse(
|
|
request, "login.html",
|
|
{
|
|
"next_path": _safe_next(next),
|
|
"error": error,
|
|
"ref": ref_norm if referrer else None,
|
|
"referrer_present": referrer is not None,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login_submit(
|
|
request: Request,
|
|
email: str = Form(...),
|
|
next: str | None = Form(default=None),
|
|
ref: str | None = Form(default=None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
s = get_settings()
|
|
# Look up the referrer up front so a bad code doesn't pollute the
|
|
# rest of the flow. Self-referral protection lives in
|
|
# referral_service.link_new_user.
|
|
ref_norm = referral_service.normalise_code(ref) if ref else None
|
|
referrer = (
|
|
await referral_service.lookup_referrer(session, ref_norm)
|
|
if ref_norm else None
|
|
)
|
|
|
|
# Track whether THIS request creates the user row (i.e. a referral
|
|
# capture window). Cleanest way: probe for existence first.
|
|
from app.services.auth_service import get_user_by_email
|
|
was_new = (await get_user_by_email(session, email)) is None
|
|
|
|
try:
|
|
user = await get_or_create_user(
|
|
session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED,
|
|
)
|
|
except AuthError as e:
|
|
return templates.TemplateResponse(
|
|
request, "login.html",
|
|
{"next_path": _safe_next(next), "error": str(e), "email": email,
|
|
"ref": ref_norm if referrer else None,
|
|
"referrer_present": referrer is not None},
|
|
status_code=400,
|
|
)
|
|
|
|
# First-time signup with a valid referrer → persist the linkage now.
|
|
# We do this BEFORE OTP-verify because the row is already created;
|
|
# if the user abandons OTP we'll have an orphan link but that's
|
|
# harmless audit data.
|
|
if was_new and referrer is not None:
|
|
await referral_service.link_new_user(session, user, referrer)
|
|
|
|
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
|
# last 60s we just reuse the existing one (silently) to avoid
|
|
# spamming the user's inbox on a refreshed form submit.
|
|
allowed, _ = await otp_service.can_request_new(session, user.email)
|
|
if allowed:
|
|
await _issue_and_send_otp(session, user.email)
|
|
|
|
resp = RedirectResponse(url="/verify", status_code=303)
|
|
# Stash the referral code on the pending cookie too — handy for
|
|
# showing the "invited" badge on the /verify page so the friend
|
|
# knows the discount is still tracking.
|
|
_set_pending_cookie(
|
|
resp, user.email, user.id,
|
|
ref=ref_norm if referrer is not None else None,
|
|
)
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verify (code entry)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/verify", response_class=HTMLResponse)
|
|
async def verify_page(request: Request, error: str | None = None, sent: str | None = None):
|
|
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
|
pending = verify_pending(cookie) if cookie else None
|
|
if pending is None:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
return templates.TemplateResponse(
|
|
request, "verify.html",
|
|
{"email": pending["email"], "error": error, "sent": sent,
|
|
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
|
|
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
|
|
)
|
|
|
|
|
|
@router.post("/verify")
|
|
async def verify_submit(
|
|
request: Request,
|
|
code: str = Form(...),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
|
pending = verify_pending(cookie) if cookie else None
|
|
if pending is None:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
email = pending["email"]
|
|
try:
|
|
await otp_service.verify(session, email, code)
|
|
except otp_service.OTPError as e:
|
|
return templates.TemplateResponse(
|
|
request, "verify.html",
|
|
{"email": email, "error": str(e),
|
|
"ttl_minutes": otp_service.OTP_TTL_MINUTES,
|
|
"resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS},
|
|
status_code=400,
|
|
)
|
|
|
|
user = await get_user(session, pending["uid"])
|
|
if user is None:
|
|
# User row vanished between cookie issue and verify. Restart flow.
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
is_first_login = user.last_login_at is None
|
|
user.last_login_at = utcnow()
|
|
# Default opt-in is set on User row creation; we don't touch it here.
|
|
# The one-time welcome email below explains the digest and the Settings
|
|
# opt-out path — re-applying a checkbox state on every login would
|
|
# silently re-subscribe users who explicitly opted out later.
|
|
await session.commit()
|
|
log.info("user.login", user_id=user.id, email=email)
|
|
|
|
# First-login welcome email — best effort. SMTP failure must not block
|
|
# the login itself; we log and continue. Idempotent because we commit
|
|
# last_login_at above before this point, so a retried verify won't
|
|
# re-trigger send.
|
|
if is_first_login:
|
|
try:
|
|
await send_welcome_email(email)
|
|
except Exception as e: # noqa: BLE001
|
|
log.warning("welcome_email.send_failed",
|
|
user_id=user.id, error=str(e)[:200])
|
|
|
|
resp = RedirectResponse(url="/", status_code=303)
|
|
_set_session_cookie(resp, user.id)
|
|
_clear_pending_cookie(resp)
|
|
return resp
|
|
|
|
|
|
@router.post("/verify/resend")
|
|
async def verify_resend(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
|
pending = verify_pending(cookie) if cookie else None
|
|
if pending is None:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
email = pending["email"]
|
|
allowed, wait = await otp_service.can_request_new(session, email)
|
|
if not allowed:
|
|
return RedirectResponse(
|
|
url=f"/verify?error=Please+wait+{wait}s+before+requesting+another+code",
|
|
status_code=303,
|
|
)
|
|
ok = await _issue_and_send_otp(session, email)
|
|
msg = "A new code has been sent" if ok else "Could not send email — try again shortly"
|
|
return RedirectResponse(url=f"/verify?sent={msg}", status_code=303)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_LOGOUT_HTML = """<!doctype html><html lang="en"><head>
|
|
<meta charset="utf-8">
|
|
<title>Signing out…</title>
|
|
<meta http-equiv="refresh" content="0;url=/login">
|
|
<script>
|
|
// Wipe per-user browser state before the redirect. Keeps `cassandra.theme`
|
|
// (cosmetic, no privacy concern) so the next user's first paint isn't a
|
|
// white-flash. The meta-refresh above is the no-JS fallback for the redirect;
|
|
// without JS, localStorage isn't cleared, but base.html's user-mismatch
|
|
// guard catches the next authenticated page load.
|
|
(function() {
|
|
try {
|
|
var theme = localStorage.getItem('cassandra.theme');
|
|
localStorage.clear();
|
|
if (theme) localStorage.setItem('cassandra.theme', theme);
|
|
sessionStorage.clear();
|
|
} catch (e) {}
|
|
window.location.replace('/login');
|
|
})();
|
|
</script>
|
|
</head><body>Signing out…</body></html>"""
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request):
|
|
resp = HTMLResponse(content=_LOGOUT_HTML)
|
|
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
|
_clear_pending_cookie(resp)
|
|
return resp
|
|
|
|
|
|
@router.get("/logout")
|
|
async def logout_get(request: Request):
|
|
return await logout(request)
|