auth: affirmative versioned sign-up acknowledgement (EN + IT)
Replaces the passive "by signing in you agree…" paragraph on /login with
an active un-pre-ticked checkbox over three short statements that the
user actively accepts. Each acceptance is recorded against a code-level
version pointer, so a future wording revision bumps the version and
prompts every user again.
- New app/legal.py::ACKNOWLEDGEMENT_VERSION (currently 1).
- New UserAcknowledgement model + migration 0027 (user_id FK CASCADE,
version, lang, accepted_at, composite index on user_id+version).
- auth_service: has_acknowledged_current() and record_acknowledgement()
helpers; POST /login validates the checkbox, falls through to a 400
with the localised error otherwise, and writes a row iff the user has
no current-version row (so existing-already-accepted users don't
produce duplicates).
- GET /login: language detection mirrors the landing's
detect_public_lang(); ?lang=en|it overrides; stamps the rtm.lang
cookie; passes the locale dict + version into the template.
- login.html: EN/IT pill, localised lede/banner/legal footer, required
checkbox in an acknowledgement block, hidden lang+ack_version fields.
Submit disabled until the box is ticked (UX polish; the server check
is what carries weight).
- locales/{en,it}.yaml: new auth.ack.* section with TODO(legal) marker.
Wording matches the brief's substance pending solicitor sign-off.
- tests/test_signup_acknowledgement.py: 10 tests (EN + IT rejection,
one row per acceptance, displayed-lang recorded, idempotent on
current version, version-bump writes new row, helper unit tests).
The acknowledgement strengthens the user-civil-claim vector — combined
with the liability cap and the Ltd, it makes "I was misled into thinking
this was advice" much harder to argue. It does NOT move the regulatory
perimeter, which is governed by the content discipline shipped in
47dce1a. Belt-and-braces, not a substitute.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
47dce1a1a4
commit
f3ac65f8f7
9 changed files with 714 additions and 13 deletions
|
|
@ -34,13 +34,38 @@ from app.auth import (
|
|||
)
|
||||
from app.config import get_settings
|
||||
from app.db import get_session, utcnow
|
||||
from app.legal import ACKNOWLEDGEMENT_VERSION
|
||||
from app.logging import get_logger
|
||||
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
||||
from app.services.auth_service import (
|
||||
AuthError,
|
||||
get_or_create_user,
|
||||
get_user,
|
||||
has_acknowledged_current,
|
||||
record_acknowledgement,
|
||||
)
|
||||
from app.services import otp_service, referral_service
|
||||
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
|
||||
from app.services.locales import ACTIVE_PUBLIC_LANGS, detect_public_lang, get_locale
|
||||
from app.templates_env import templates
|
||||
|
||||
|
||||
_LANG_COOKIE = "rtm.lang"
|
||||
|
||||
|
||||
def _resolve_login_lang(request: Request, explicit: str | None) -> str:
|
||||
"""Resolve the language to render /login in. An explicit ?lang= wins
|
||||
(used by the on-page EN/IT pill); otherwise the same detection
|
||||
landing.py uses — cookie → Accept-Language → cf-ipcountry → en."""
|
||||
if explicit and explicit in ACTIVE_PUBLIC_LANGS:
|
||||
return explicit
|
||||
return detect_public_lang(
|
||||
cookie_lang=request.cookies.get(_LANG_COOKIE),
|
||||
accept_language=request.headers.get("accept-language"),
|
||||
cf_country=request.headers.get("cf-ipcountry"),
|
||||
user_lang=None,
|
||||
)
|
||||
|
||||
|
||||
log = get_logger("auth_router")
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
|
@ -111,6 +136,7 @@ async def login_page(
|
|||
next: str | None = None,
|
||||
error: str | None = None,
|
||||
ref: str | None = None,
|
||||
lang: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# If a valid referral code is supplied, surface a small "invited"
|
||||
|
|
@ -121,15 +147,27 @@ async def login_page(
|
|||
await referral_service.lookup_referrer(session, ref_norm)
|
||||
if ref_norm else None
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
resolved_lang = _resolve_login_lang(request, lang)
|
||||
response = 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,
|
||||
"lang": resolved_lang,
|
||||
"t": get_locale(resolved_lang),
|
||||
"ack_version": ACKNOWLEDGEMENT_VERSION,
|
||||
},
|
||||
)
|
||||
# Stamp the language cookie if the user used the on-page pill so
|
||||
# subsequent visits land on the same translation without re-detecting.
|
||||
if lang and lang in ACTIVE_PUBLIC_LANGS:
|
||||
response.set_cookie(
|
||||
_LANG_COOKIE, lang,
|
||||
max_age=60 * 60 * 24 * 365, samesite="lax", httponly=False,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
|
|
@ -138,6 +176,9 @@ async def login_submit(
|
|||
email: str = Form(...),
|
||||
next: str | None = Form(default=None),
|
||||
ref: str | None = Form(default=None),
|
||||
lang: str | None = Form(default=None),
|
||||
acknowledged: str | None = Form(default=None),
|
||||
ack_version: int | None = Form(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
s = get_settings()
|
||||
|
|
@ -150,6 +191,27 @@ async def login_submit(
|
|||
if ref_norm else None
|
||||
)
|
||||
|
||||
# Resolve language from the form (what the user actually saw) or
|
||||
# fall back to detection. This is the language we'll record on the
|
||||
# acknowledgement row if one gets written.
|
||||
resolved_lang = _resolve_login_lang(request, lang)
|
||||
t = get_locale(resolved_lang)
|
||||
|
||||
# Affirmative-acknowledgement check. Browsers only POST the checkbox
|
||||
# name when ticked, so any truthy value here means accepted.
|
||||
if not acknowledged:
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"next_path": _safe_next(next),
|
||||
"error": str(t.auth.ack.error_required),
|
||||
"email": email,
|
||||
"ref": ref_norm if referrer else None,
|
||||
"referrer_present": referrer is not None,
|
||||
"lang": resolved_lang, "t": t,
|
||||
"ack_version": ACKNOWLEDGEMENT_VERSION},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
|
@ -164,7 +226,9 @@ async def login_submit(
|
|||
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},
|
||||
"referrer_present": referrer is not None,
|
||||
"lang": resolved_lang, "t": t,
|
||||
"ack_version": ACKNOWLEDGEMENT_VERSION},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
|
@ -175,6 +239,15 @@ async def login_submit(
|
|||
if was_new and referrer is not None:
|
||||
await referral_service.link_new_user(session, user, referrer)
|
||||
|
||||
# Record the acknowledgement at the current version, idempotently.
|
||||
# If the user has already accepted this version we skip — every
|
||||
# sign-in needn't write a row, only every version bump does.
|
||||
if not await has_acknowledged_current(session, user):
|
||||
await record_acknowledgement(session, user, resolved_lang)
|
||||
log.info("auth.acknowledgement_recorded",
|
||||
user_id=user.id, version=ACKNOWLEDGEMENT_VERSION,
|
||||
lang=resolved_lang)
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue