phase D milestones 1+2: referral system + paid-access gate

Lays the billing-prep spine before Paddle lands in D.3.

D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
  ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
  accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
  referred, created_at, converted_at, credited_at). converted_at /
  credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
  pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
  with one-click copy, pending/converted/active-credits stats grid.
  Settings nav link added to the top bar.

Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)

D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
  paid-tier access. Null = no credit. Populated by admin CLI now and the
  D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
  (active / source / expires_at / days_remaining), is_paid_active() with
  admin-bearer-token bypass, and a require_paid FastAPI dependency that
  raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
  day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
  grant-credit is idempotent — extends from max(now, current expiry) so
  re-running the command never erodes an existing grant.

Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
This commit is contained in:
Giorgio Gilestro 2026-05-21 23:25:35 +01:00
parent 2013bfa8cc
commit 9759080134
18 changed files with 1159 additions and 21 deletions

View file

@ -87,15 +87,26 @@ def _pending_serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(secret, salt="cassandra-pending-v1")
def sign_pending(email: str, user_id: int) -> str:
return _pending_serializer().dumps({"email": email, "uid": int(user_id)})
def sign_pending(email: str, user_id: int, ref: str | None = None) -> str:
"""Signed payload for the pending-verify cookie. Carries the email
+ user_id under verification, and optionally a referral code captured
at signup (so it survives the GET POST /verify hop)."""
payload: dict = {"email": email, "uid": int(user_id)}
if ref:
payload["ref"] = ref
return _pending_serializer().dumps(payload)
def verify_pending(cookie: str) -> dict | None:
"""Returns {"email": str, "uid": int} or None if signature/expiry bad."""
"""Returns {"email": str, "uid": int, "ref": str|None} or None if
signature/expiry bad."""
try:
data = _pending_serializer().loads(cookie, max_age=PENDING_TTL_SECONDS)
return {"email": str(data["email"]), "uid": int(data["uid"])}
return {
"email": str(data["email"]),
"uid": int(data["uid"]),
"ref": data.get("ref"),
}
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
return None

136
app/cli.py Normal file
View file

@ -0,0 +1,136 @@
"""Admin CLI — runs inside the `app` container.
Usage from the host::
docker compose exec app python -m app.cli grant-credit <email> <months>
docker compose exec app python -m app.cli revoke-credit <email>
docker compose exec app python -m app.cli show-status <email>
`grant-credit` is idempotent: it extends `users.credit_until` from
``max(now, current_credit_until)``, so granting "1 month" twice gives
two months, not one (avoids accidental erosion of an existing grant
when re-running the command).
This is the manual lever for Phase D.2. In D.3 the Paddle webhook will
call the same helper for both sides of a referral conversion.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import get_engine, get_session_factory
from app.models import User
from app.services.access import _aware, paid_status
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
async def _get_user_by_email(session, email: str) -> User | None:
return (await session.execute(
select(User).where(User.email == email)
)).scalar_one_or_none()
async def grant_credit(email: str, months: float) -> int:
if months <= 0:
print(f"error: months must be positive (got {months})", file=sys.stderr)
return 2
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
anchor = max(_utcnow(), _aware(user.credit_until) or _utcnow())
# 30-day months — simple, predictable, no calendar arithmetic.
days = int(round(months * 30))
new_expiry = anchor + timedelta(days=days)
user.credit_until = new_expiry
await session.commit()
# Refresh status snapshot from the just-committed value.
st = paid_status(user)
print(
f"granted {months} month(s) to {email}: "
f"credit_until={new_expiry.isoformat()} "
f"(~{st.days_remaining} days remaining)"
)
return 0
async def revoke_credit(email: str) -> int:
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
user.credit_until = None
await session.commit()
print(f"revoked: credit_until cleared for {email}")
return 0
async def show_status(email: str) -> int:
factory = get_session_factory()
async with factory() as session:
user = await _get_user_by_email(session, email)
if user is None:
print(f"error: no user with email {email!r}", file=sys.stderr)
return 1
st = paid_status(user)
print(f"email: {user.email}")
print(f"tier: {user.tier}")
print(f"credit_until: {user.credit_until or ''}")
print(f"paid active: {st.active} (source={st.source or ''})")
if st.expires_at:
print(f"expires in: {st.days_remaining} days")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI")
sub = p.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("grant-credit", help="Extend a user's paid-credit window")
g.add_argument("email")
g.add_argument("months", type=float)
r = sub.add_parser("revoke-credit", help="Clear a user's credit_until")
r.add_argument("email")
s = sub.add_parser("show-status", help="Print paid-tier status for a user")
s.add_argument("email")
return p
async def _dispatch(args) -> int:
"""Run the chosen sub-command, then dispose the async engine cleanly
so aiomysql's __del__ doesn't squawk at interpreter shutdown about a
closed event loop."""
try:
if args.cmd == "grant-credit":
return await grant_credit(args.email, args.months)
if args.cmd == "revoke-credit":
return await revoke_credit(args.email)
if args.cmd == "show-status":
return await show_status(args.email)
return 2
finally:
await get_engine().dispose()
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return asyncio.run(_dispatch(args))
if __name__ == "__main__":
sys.exit(main())

View file

@ -159,8 +159,48 @@ class User(Base):
settings_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Referrals (Phase D.1). The code is unique + URL-safe; generated on
# first need rather than at row creation so existing accounts get one
# the next time they hit /settings.
referral_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
referred_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
# Paid-tier credit window (Phase D.2). Null = no credit. When set and
# > now(), the user gets paid-tier features regardless of `tier`.
# Populated by admin CLI (manual grants) or Paddle webhook (D.3).
credit_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
__table_args__ = (
UniqueConstraint("email", name="uq_users_email"),
UniqueConstraint("referral_code", name="uq_users_referral_code"),
)
class Referral(Base):
"""One row per captured (referrer, referred) pair. Created at signup
when the new user supplied a valid `?ref=<code>`. The conversion
fields (`converted_at`, `credited_at`) stay null until the referred
user makes their first paid subscription Phase D.3 fills them in
via the Paddle webhook."""
__tablename__ = "referrals"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
referrer_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
referred_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
converted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
credited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("referred_user_id", name="uq_referrals_referred"),
Index("ix_referrals_referrer", "referrer_user_id"),
)
class EmailOTP(Base):

View file

@ -36,7 +36,7 @@ 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
from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp
from app.templates_env import templates
@ -67,10 +67,15 @@ def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
)
def _set_pending_cookie(response: RedirectResponse, email: str, user_id: int) -> None:
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),
value=sign_pending(email, user_id, ref=ref),
max_age=PENDING_TTL_SECONDS,
httponly=True,
samesite="lax",
@ -101,10 +106,29 @@ async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool:
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str | None = None, error: str | None = None):
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},
{
"next_path": _safe_next(next),
"error": error,
"ref": ref_norm if referrer else None,
"referrer_present": referrer is not None,
},
)
@ -113,9 +137,24 @@ 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,
@ -123,10 +162,19 @@ async def login_submit(
except AuthError as e:
return templates.TemplateResponse(
request, "login.html",
{"next_path": _safe_next(next), "error": str(e), "email": email},
{"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.
@ -135,7 +183,13 @@ async def login_submit(
await _issue_and_send_otp(session, user.email)
resp = RedirectResponse(url="/verify", status_code=303)
_set_pending_cookie(resp, user.email, user.id)
# 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

View file

@ -8,10 +8,12 @@ from fastapi.responses import HTMLResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_token
from app.auth import CurrentUser, require_auth, require_token
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import StrategicLog
from app.models import Referral, StrategicLog, User
from app.services.access import paid_status
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates
router = APIRouter(dependencies=[Depends(require_token)])
@ -84,3 +86,51 @@ async def log_page_day(
):
target = await _resolve_log_date(session, day)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
session: AsyncSession = Depends(get_session),
principal: CurrentUser = Depends(require_auth),
):
"""Per-user settings. Currently shows email, tier, and the referral
block (own code + invite link + counts of pending/converted
referrals). The Credit / Paddle pieces land in D.3."""
user = principal.user
if user is None:
# Bearer-token admin path — no per-user settings to show.
return templates.TemplateResponse(
request, "settings.html",
{"user": None, "invite_url": None,
"pending_count": 0, "converted_count": 0},
)
# Lazily assign a referral code on first visit.
user = await assign_code_if_missing(session, user)
# Stats: how many people have signed up with their code so far, and
# how many of those converted (paid). D.3 will fill `converted_at`.
pending_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_(None))
)).scalar() or 0
converted_count = (await session.execute(
select(func.count(Referral.id))
.where(Referral.referrer_user_id == user.id)
.where(Referral.converted_at.is_not(None))
)).scalar() or 0
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
return templates.TemplateResponse(
request, "settings.html",
{
"user": user,
"invite_url": invite_url,
"pending_count": int(pending_count),
"converted_count": int(converted_count),
"paid": paid_status(user),
},
)

View file

@ -42,6 +42,7 @@ from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import Quote, QuoteDaily
from app.services import fx, portfolio_analysis, ticker_universe
from app.services.access import require_paid
from app.services.csv_import import CSVImportError, parse_t212_csv
from app.services.instrument_map import resolve_slice
from app.services.market import fetch as market_fetch
@ -310,7 +311,7 @@ async def parse_portfolio(
# ---------------------------------------------------------------------------
@router.post("/analyze")
@router.post("/analyze", dependencies=[Depends(require_paid)])
async def analyze_portfolio(
request: Request,
session: AsyncSession = Depends(get_session),
@ -318,7 +319,10 @@ async def analyze_portfolio(
"""Generate AI commentary for the supplied pie. The pie is held in
memory only for the duration of the LLM call; nothing about holdings
is persisted. The ai_calls ledger row records tokens + cost, never
holdings."""
holdings.
Gated behind ``require_paid`` (Phase D.2): free-tier users get 402.
Admin bearer-token bypasses the gate for testing."""
# Read JSON body manually so we can enforce a hard size cap. FastAPI's
# default body limit is generous; we want tighter control here.
body = await request.body()

95
app/services/access.py Normal file
View file

@ -0,0 +1,95 @@
"""Paid-tier access checks.
Two sources can grant paid access:
1. ``user.tier in {"paid", "enterprise"}`` set by Paddle webhook in
Phase D.3 once a subscription is active.
2. ``user.credit_until > now()`` non-subscription credit. Currently
populated by the admin CLI (`python -m app.cli grant-credit`) and, in
D.3, by the referral-conversion path (3 months at 50% off).
Either is sufficient. We use a single ``paid_status`` function so the
Settings page can show *why* a user has paid access ("paid subscription"
vs "credit, 47 days left") without duplicating the rules.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from app.auth import CurrentUser, require_auth
from app.models import User
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
@dataclass(frozen=True)
class PaidStatus:
"""Snapshot of paid-tier status for one user."""
active: bool
source: str | None # "tier" | "credit" | None
expires_at: datetime | None # only meaningful when source == "credit"
days_remaining: int | None # only meaningful when source == "credit"
def _aware(dt: datetime | None) -> datetime | None:
"""MariaDB round-trips DateTime(timezone=True) as a naive UTC value
via aiomysql. Normalise to tz-aware so comparisons against utcnow()
never raise."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def paid_status(user: User | None) -> PaidStatus:
"""Compute paid-tier status for a User row. ``user=None`` (anonymous
or admin bearer-token) returns inactive callers should special-case
admin separately via ``is_paid_active``."""
if user is None:
return PaidStatus(False, None, None, None)
if user.tier in ("paid", "enterprise"):
return PaidStatus(True, "tier", None, None)
cu = _aware(getattr(user, "credit_until", None))
if cu is not None and cu > _utcnow():
days = max(0, (cu - _utcnow()).days)
return PaidStatus(True, "credit", cu, days)
return PaidStatus(False, None, None, None)
def is_paid_active(principal: CurrentUser | User | None) -> bool:
"""True if the principal has paid-tier access right now. Admin
bearer-token (``CurrentUser.is_admin=True``) always passes."""
if principal is None:
return False
if isinstance(principal, CurrentUser):
if principal.is_admin:
return True
return paid_status(principal.user).active
return paid_status(principal).active
async def require_paid(
principal: CurrentUser = Depends(require_auth),
) -> CurrentUser:
"""FastAPI dependency for paid-only endpoints. Returns the principal
on success; raises 402 Payment Required otherwise.
402 is the semantically-correct code for "auth succeeded but plan
insufficient" — distinct from 401 (not authenticated) and 403
(authenticated but forbidden by ACL). Frontends key off it to show
the upgrade prompt rather than redirecting to /login."""
if is_paid_active(principal):
return principal
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "paid_required",
"message": "This feature requires an active paid plan or credit.",
},
)

View file

@ -0,0 +1,119 @@
"""Referral-code generation, lookup, and signup-time linkage.
D.1 lays down the bookkeeping only actual credit application happens
in D.3 when the Paddle webhook fires. The flow:
1. /login renders an "invited" banner when the URL carries `?ref=<code>`.
2. The code travels through the email-OTP flow inside the pending cookie
so it survives the GET /login POST /login /verify hops.
3. When the new user's row is first created (POST /login on an unknown
email), `referred_by_user_id` is set and a `Referral` row is written.
4. On the new user's first paid subscription (D.3), we read the
`Referral` row to apply discounts to both parties.
The code itself is 8 characters from an unambiguous alphabet so users
can read it off a phone screen or dictate it over the phone.
"""
from __future__ import annotations
import secrets
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.logging import get_logger
from app.models import Referral, User
log = get_logger("referral")
# Unambiguous alphabet — no 0/O, no 1/I/L. 32 chars → 8 positions ≈ 1e12
# combinations, plenty for our scale, and a unique-constraint catches
# collisions if we ever generate the same one twice.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LEN = 8
def generate_code() -> str:
"""Cryptographically random 8-char code from the unambiguous alphabet."""
return "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LEN))
def normalise_code(raw: str | None) -> str | None:
"""Trim, uppercase, strip non-alphabet characters. Used on inbound
`?ref=<code>` params so users can paste with spaces / lowercase.
Returns None if the result isn't a plausible code."""
if not raw:
return None
cleaned = "".join(c for c in raw.upper() if c in _ALPHABET)
if len(cleaned) != _CODE_LEN:
return None
return cleaned
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
"""Generate + persist a referral code on `user` if they don't have
one yet. Retries on the (very rare) collision."""
if user.referral_code:
return user
for _ in range(8):
code = generate_code()
existing = (await session.execute(
select(User.id).where(User.referral_code == code)
)).scalar_one_or_none()
if existing is None:
user.referral_code = code
await session.commit()
await session.refresh(user)
log.info("referral.code_assigned", user_id=user.id, code=code)
return user
# 8 collisions in a row would be a statistical event we'd want to
# know about.
raise RuntimeError("referral_service: exhausted code-collision retries")
async def lookup_referrer(session: AsyncSession, code: str | None) -> User | None:
"""Return the User whose `referral_code` matches, or None. Normalises
the input via `normalise_code` so URL-paste variations all resolve."""
code = normalise_code(code)
if not code:
return None
return (await session.execute(
select(User).where(User.referral_code == code)
)).scalar_one_or_none()
async def link_new_user(
session: AsyncSession,
new_user: User,
referrer: User | None,
) -> Referral | None:
"""Record a referral if the supplied referrer is valid. Idempotent
(safe to call multiple times for the same new user the unique
constraint on `referred_user_id` makes duplicate inserts a no-op).
Self-referral is silently rejected.
"""
if referrer is None or new_user.id is None or referrer.id == new_user.id:
return None
if new_user.referred_by_user_id is not None:
# Already linked; this user can't be referred twice.
return None
new_user.referred_by_user_id = referrer.id
ref = Referral(
referrer_user_id=referrer.id,
referred_user_id=new_user.id,
created_at=utcnow(),
)
session.add(ref)
await session.commit()
await session.refresh(new_user)
await session.refresh(ref)
log.info(
"referral.linked",
referrer_id=referrer.id, referred_id=new_user.id,
)
return ref

View file

@ -774,6 +774,7 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
.badge--analysis-speculative { color: var(--accent); }
.badge--ver { color: var(--dim); }
.badge--ok { color: var(--positive); border-color: var(--positive); }
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
@ -882,6 +883,139 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin-bottom: 14px;
font-family: var(--font-mono);
}
.auth-info--invited {
/* Slightly warmer / friendlier shading for the referral banner. */
border-left-color: var(--positive);
background: color-mix(in srgb, var(--positive) 7%, transparent);
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
}
.auth-info--invited strong { color: var(--positive); font-weight: 600; }
/* --- Settings page --------------------------------------------------- */
.settings-row {
display: flex;
align-items: baseline;
gap: 14px;
padding: 8px 0;
border-bottom: 1px solid var(--surface-2);
font-size: 13px;
}
.settings-row__label {
width: 110px;
flex-shrink: 0;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10.5px;
font-family: var(--font-mono);
}
.settings-row__value { color: var(--text); }
.settings-row__hint {
color: var(--dim);
font-size: 11px;
margin-left: 8px;
}
.settings-section { margin-top: 22px; }
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.settings-section__head::before { content: "▸ "; color: var(--accent); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;
line-height: 1.55;
margin: 0 0 14px;
}
.settings-section__lede strong { color: var(--positive); font-weight: 600; }
.invite-block {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 14px 16px;
}
.invite-block__label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 4px;
}
.invite-block__label:not(:first-child) { margin-top: 12px; }
.invite-block__code {
font-family: var(--font-mono);
font-size: 22px;
letter-spacing: 0.32em;
color: var(--accent);
background: var(--surface);
padding: 10px 14px;
border: 1px solid var(--accent);
text-align: center;
user-select: all;
}
.invite-block__link {
display: flex;
gap: 6px;
}
.invite-block__link input {
flex: 1;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 10px;
font-family: var(--font-mono);
font-size: 12px;
}
.invite-block__link button {
background: var(--accent);
color: var(--bg);
border: 0;
padding: 0 14px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.invite-block__link button:hover { opacity: 0.85; }
.invite-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-top: 16px;
}
.invite-stats > div {
background: var(--surface);
padding: 10px 14px;
}
.invite-stats__label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.invite-stats__value {
font-family: var(--font-mono);
font-size: 18px;
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: 4px;
}
.auth-card__lede {
font-size: 12.5px;
color: var(--muted);

View file

@ -139,10 +139,11 @@
<header class="app-header">
<div class="brand">Cassandra</div>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
</nav>
<div class="header-right">
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"

View file

@ -18,6 +18,14 @@
<div class="auth-card__brand">Cassandra</div>
<div class="auth-card__hint">sign in with email</div>
{% if referrer_present %}
<div class="auth-info auth-info--invited">
<strong>You've been invited.</strong>
When you subscribe, you and your friend both get
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
</div>
{% endif %}
<p class="auth-card__lede">
Enter your email and we'll send you a 6-digit code. No password.
First-time visitors get an account; returning visitors get a sign-in.
@ -27,6 +35,7 @@
<form method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next_path or '/' }}">
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>

102
app/templates/settings.html Normal file
View file

@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Cassandra · Settings{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Settings</span>
<span class="meta">your account &middot; client-only data unchanged</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
{% if not user %}
<div class="empty">no per-user settings (admin bearer-token session)</div>
{% else %}
<div class="settings-row">
<div class="settings-row__label">Email</div>
<div class="settings-row__value">{{ user.email }}</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value">
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
</span>
{% if paid and paid.active %}
{% if paid.source == "credit" %}
<span class="settings-row__hint">
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% else %}
<span class="settings-row__hint">Paid features unlock with Paddle (D.3) or invite credits.</span>
{% endif %}
</div>
</div>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
<p class="settings-section__lede">
Share your invite link. When your friend subscribes, you and
they each get <strong>50% off for 3 months</strong>.
</p>
<div class="invite-block">
<label class="invite-block__label">Your code</label>
<div class="invite-block__code">{{ user.referral_code }}</div>
<label class="invite-block__label">Invite link</label>
<div class="invite-block__link">
<input type="text" id="invite-url" readonly value="{{ invite_url }}">
<button type="button" id="invite-copy">Copy</button>
</div>
</div>
<div class="invite-stats">
<div>
<div class="invite-stats__label">Pending signups</div>
<div class="invite-stats__value">{{ pending_count }}</div>
</div>
<div>
<div class="invite-stats__label">Converted (paid)</div>
<div class="invite-stats__value">{{ converted_count }}</div>
</div>
<div>
<div class="invite-stats__label">Active credits</div>
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
</div>
</div>
</div>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
{% endif %}
</div>
</section>
<script>
(function () {
var btn = document.getElementById('invite-copy');
var fld = document.getElementById('invite-url');
if (!btn || !fld) return;
btn.addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(fld.value);
var orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function () { btn.textContent = orig; }, 1500);
} catch (e) {
// Fallback for older browsers: select the input.
fld.select();
}
});
})();
</script>
{% endblock %}