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:
parent
a07fd144ea
commit
00211fec02
8 changed files with 553 additions and 124 deletions
|
|
@ -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") == "/"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue