ui: collapsible settings sections + welcome-email + larger auth inputs

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>
This commit is contained in:
Giorgio Gilestro 2026-05-26 22:32:59 +02:00
parent a07fd144ea
commit 00211fec02
8 changed files with 553 additions and 124 deletions

View file

@ -1,4 +1,10 @@
"""Verify-POST persists the subscribe_to_digests form field."""
"""/verify behaviour: returning users keep their digest preference, and
first-login triggers the welcome email exactly once.
The verify page used to carry a "Email me the digest" checkbox; that
was removed (it was misleading on repeat logins). Default-opt-in lives
in the User row at creation; per-user changes happen on /settings.
"""
from __future__ import annotations
import asyncio
@ -35,60 +41,11 @@ def _build(tmp_path):
return TestClient(app), pending
def test_verify_with_unchecked_subscribe_disables_opt_in(tmp_path, monkeypatch):
from app.services import otp_service
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"}, # subscribe_to_digests omitted = unchecked
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303, r.text
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
assert u.email_digest_opt_in is False
asyncio.run(_check())
def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch):
from app.services import otp_service
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000", "subscribe_to_digests": "on"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
assert u.email_digest_opt_in is True
asyncio.run(_check())
def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
"""A user who unsubscribed (via Settings or the one-click link) must
not be silently re-enrolled when they log in again, even if the verify
page's default-checked checkbox gets submitted."""
not be silently re-enrolled when they log in again. The handler now
never touches email_digest_opt_in, so this is a regression guard
against accidentally adding that back."""
from datetime import datetime, timezone
from app.services import otp_service
@ -110,12 +67,9 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
await s.commit()
asyncio.run(_make_returning())
# They log in again. The verify checkbox is default-checked in the
# template, so the form will submit "on" — but the handler must NOT
# apply that to a returning user.
r = client.post(
"/verify",
data={"code": "000000", "subscribe_to_digests": "on"},
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
@ -128,3 +82,98 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
u = await s.get(User, 10)
assert u.email_digest_opt_in is False, "returning user re-enrolled"
asyncio.run(_check())
def test_first_login_triggers_welcome_email(tmp_path, monkeypatch):
"""A user signing in for the first time gets exactly one welcome
email. The send is best-effort failure must not block login."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 1, "first login should send a welcome email"
assert send_mock.await_args.args == ("newbie@x",)
def test_returning_user_login_does_not_resend_welcome(tmp_path, monkeypatch):
"""The welcome email is one-shot: a returning user (last_login_at
is not None) must not get a second copy."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
# Mark the user as already-known.
async def _make_returning():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
await s.commit()
asyncio.run(_make_returning())
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 0, "returning user should not re-get welcome"
def test_welcome_email_failure_does_not_block_login(tmp_path, monkeypatch):
"""SMTP errors are best-effort — the user still gets a session cookie
and lands on /. We rely on a log line for operational visibility."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
async def _boom(*args, **kwargs):
raise RuntimeError("SMTP down")
monkeypatch.setattr(auth_router, "send_welcome_email",
AsyncMock(side_effect=_boom))
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
# Login still succeeds; redirect to dashboard, session cookie set.
assert r.status_code == 303, r.text
assert r.headers.get("location") == "/"