diff --git a/alembic/versions/0027_user_acknowledgements.py b/alembic/versions/0027_user_acknowledgements.py new file mode 100644 index 0000000..7332ee2 --- /dev/null +++ b/alembic/versions/0027_user_acknowledgements.py @@ -0,0 +1,61 @@ +"""user_acknowledgements: affirmative sign-up acknowledgement audit table. + +Revision ID: 0027 +Revises: 0026 +Create Date: 2026-05-29 + +One row per (user, version) acceptance of the /login acknowledgement. +See app/models.py::UserAcknowledgement and app/legal.py for context. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0027" +down_revision: Union[str, None] = "0026" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_acknowledgements", + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True, + ), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("version", sa.SmallInteger(), nullable=False), + sa.Column("lang", sa.String(length=8), nullable=False), + sa.Column( + "accepted_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + op.create_index( + "ix_user_acknowledgements_user_version", + "user_acknowledgements", ["user_id", "version"], + ) + op.create_index( + "ix_user_acknowledgements_accepted_at", + "user_acknowledgements", ["accepted_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_user_acknowledgements_accepted_at", + table_name="user_acknowledgements", + ) + op.drop_index( + "ix_user_acknowledgements_user_version", + table_name="user_acknowledgements", + ) + op.drop_table("user_acknowledgements") diff --git a/app/legal.py b/app/legal.py new file mode 100644 index 0000000..94f27f3 --- /dev/null +++ b/app/legal.py @@ -0,0 +1,19 @@ +"""Legal-surface constants. + +Currently a one-liner: the version of the sign-up acknowledgement text +the user actively accepts at ``/login``. + +Bumping this constant re-prompts every existing user on their next +sign-in, because the new value won't match their stored +``user_acknowledgements.version`` rows. The recorded row is what gives +the acknowledgement its evidentiary weight; the constant is the pointer +that says "the current canonical text is at version N". The text itself +lives in ``app/locales/{en,it}.yaml`` under ``auth.ack``. + +When the solicitor finalises wording, edit the YAML files **and** bump +this number in the same change so the version pointer stays accurate. +""" +from __future__ import annotations + + +ACKNOWLEDGEMENT_VERSION: int = 1 diff --git a/app/locales/en.yaml b/app/locales/en.yaml index d8c9bb4..caef603 100644 --- a/app/locales/en.yaml +++ b/app/locales/en.yaml @@ -118,3 +118,17 @@ footer: meta: description: "Understand markets. Don't gamble on them." + +# TODO(legal): final wording pending solicitor sign-off. Bumping +# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user +# on next sign-in and writes a fresh row at the new version. +auth: + ack: + heading: "Before you create an account, please confirm:" + items: + - "This service is editorial and educational commentary on public market data. It is not financial, investment, or tax advice and is not a personal recommendation." + - "The operator is not authorised or regulated to provide investment advice. Nothing here is a recommendation to buy, sell, or hold any investment." + - "I make my own investment decisions and am solely responsible for them. For personal advice I will consult a regulated adviser." + checkbox_label: "I have read and accept these statements." + error_required: "Please tick the box to confirm the acknowledgement before continuing." + lang_switch_aria: "Language" diff --git a/app/locales/it.yaml b/app/locales/it.yaml index 51a8417..34f9098 100644 --- a/app/locales/it.yaml +++ b/app/locales/it.yaml @@ -124,3 +124,17 @@ footer: meta: description: "Capisci i mercati. Non scommetterci sopra." + +# TODO(legal): final wording pending solicitor sign-off. Bumping +# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user +# on next sign-in and writes a fresh row at the new version. +auth: + ack: + heading: "Prima di creare un account, conferma:" + items: + - "Questo servizio è commento editoriale ed educativo su dati di mercato pubblici. Non è consulenza finanziaria, di investimento o fiscale e non è una raccomandazione personalizzata." + - "L'operatore non è autorizzato né regolamentato a fornire consulenza in materia di investimenti. Nulla qui è una raccomandazione di acquisto, vendita o detenzione di alcuno strumento finanziario." + - "Prendo da solo le mie decisioni di investimento e ne sono unico responsabile. Per una consulenza personalizzata mi rivolgerò a un consulente regolamentato." + checkbox_label: "Ho letto e accetto queste dichiarazioni." + error_required: "Spunta la casella per confermare prima di continuare." + lang_switch_aria: "Lingua" diff --git a/app/models.py b/app/models.py index b454f76..f64d2e4 100644 --- a/app/models.py +++ b/app/models.py @@ -246,6 +246,35 @@ class ReviewerVerdict(Base): model: Mapped[str | None] = mapped_column(String(64)) +class UserAcknowledgement(Base): + """Append-only record of a user actively accepting the sign-up + acknowledgement at ``/login``. See app/legal.py for the version + constant and app/locales/{en,it}.yaml::auth.ack for the canonical text. + + One row per (user, version) acceptance. The value of this table is + evidentiary: 'user X accepted the version-N text on date D, in the + language they were shown'. A future wording revision bumps + ACKNOWLEDGEMENT_VERSION; existing users see the prompt again on next + sign-in and a new row is written at the new version.""" + __tablename__ = "user_acknowledgements" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + version: Mapped[int] = mapped_column(SmallInteger, nullable=False) + # Language the user actually saw — what they accepted is keyed to it. + lang: Mapped[str] = mapped_column(String(8), nullable=False) + accepted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, index=True, + ) + + __table_args__ = ( + Index("ix_user_acknowledgements_user_version", "user_id", "version"), + ) + + # Portfolio / PortfolioSnapshot / Position removed in Phase G — # holdings live in the browser, the server stores only the anonymous # ticker universe + public market data. diff --git a/app/routers/auth.py b/app/routers/auth.py index 28a7d4d..aa7b036 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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. diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 4791ee8..558b4d2 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -17,7 +17,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow -from app.models import User +from app.legal import ACKNOWLEDGEMENT_VERSION +from app.models import User, UserAcknowledgement class AuthError(Exception): @@ -69,3 +70,35 @@ async def get_or_create_user( await session.commit() await session.refresh(user) return user + + +async def has_acknowledged_current( + session: AsyncSession, user: User, +) -> bool: + """True iff ``user`` has a ``user_acknowledgements`` row at the + currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit + on (user_id, version); fast enough to run on every sign-in.""" + row = (await session.execute( + select(UserAcknowledgement.id) + .where(UserAcknowledgement.user_id == user.id) + .where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION) + .limit(1) + )).scalar_one_or_none() + return row is not None + + +async def record_acknowledgement( + session: AsyncSession, user: User, lang: str, +) -> None: + """Insert one ``user_acknowledgements`` row at the current version, + recording the language the user actually saw. The caller is + expected to gate on ``has_acknowledged_current`` first — this helper + does not de-duplicate so the audit trail can carry repeated + acceptances if a future caller wants them.""" + session.add(UserAcknowledgement( + user_id=user.id, + version=ACKNOWLEDGEMENT_VERSION, + lang=lang, + accepted_at=utcnow(), + )) + await session.commit() diff --git a/app/templates/login.html b/app/templates/login.html index 2cfc899..4e3e149 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,5 +1,5 @@ - +
@@ -17,40 +17,110 @@+ {% if lang == 'it' %} + Inserisci la tua email e ti invieremo un codice di 6 cifre. Niente password. + I nuovi visitatori creano un account; chi torna fa l'accesso. + {% else %} 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. + {% endif %}
{% if error %}- By signing in you agree to our - Terms and - Privacy notice, and confirm you’ve read - the financial disclaimer. + {% if lang == 'it' %} + Vedi i nostri + Termini, l’Informativa privacy + e il disclaimer finanziario. + {% else %} + See our + Terms, + Privacy notice, and + financial disclaimer. + {% endif %}