From 2cd1bf284d2870f827a757be1e4cd6847927b398 Mon Sep 17 00:00:00 2001 From: miquelmatoses Date: Tue, 14 Jul 2026 12:50:17 +0200 Subject: [PATCH] feat(auth): require verified email before claiming a beta/premium slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the disposable-email slot-farming vector on the unverified password -signup path. The "first 500 free Full Moon" grant in ensure_profile is now gated on auth_users.email_verified (migration 032): - ensure_profile: grant SQL gains _EMAIL_VERIFIED_SQL; every caller inherits the gate. Never revokes an existing grant or paid premium (still ORs with current value). - Magic-link and Google (_find_or_create_user) set email_verified = TRUE by construction, so they get the grant on first /me/profile as before. - password_signup: account created unverified, tokens still issued (usable for the free instruments), verification email sent via Resend (reuses the magic_tokens table). New POST /auth/verify-email consumes the token, sets email_verified = TRUE, and re-runs ensure_profile. - Frontend: signup shows the confirm-email card; AuthCallbackPage handles ?type=verify; confirmBody copy updated (6 locales). Backfill (032) marked existing accounts verified — no revocation; the 3 live beta grants are preserved. Tests: verified gate in both ensure_profile branches + truth table; _find_or_create_user marks verified. Co-Authored-By: Claude Opus 4.8 --- api/auth.py | 74 +++++++++++++++++++++++++++- api/emails.py | 62 +++++++++++++++++++++++ api/main.py | 40 ++++++++++----- api/tests/test_beta_grant.py | 45 +++++++++++++---- api/tests/test_email_verification.py | 60 ++++++++++++++++++++++ docs/architecture/auth.md | 23 +++++++++ src/locales/ca.json | 2 +- src/locales/da.json | 2 +- src/locales/de.json | 2 +- src/locales/en.json | 2 +- src/locales/es.json | 2 +- src/locales/fr.json | 2 +- src/pages/AuthCallbackPage.jsx | 32 ++++++++++++ src/pages/AuthPage.jsx | 17 +++++-- 14 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 api/tests/test_email_verification.py diff --git a/api/auth.py b/api/auth.py index 7282bc882..773eb9592 100644 --- a/api/auth.py +++ b/api/auth.py @@ -37,7 +37,7 @@ from pydantic import BaseModel from slowapi.errors import RateLimitExceeded # noqa: F401 — used indirectly via app handler -from emails import send_magic_link +from emails import send_magic_link, send_verify_email from limiter import limiter # --------------------------------------------------------------------------- @@ -180,6 +180,15 @@ async def _find_or_create_user( else: user = {"id": str(row["id"]), "email": row["email"]} + # Reaching this function means email ownership was proven — magic-link + # (clicked a link sent to the address) or Google OAuth (Google asserts the + # verified email). Mark the account verified so the beta/premium grant in + # ensure_profile can fire. This also verifies a pre-existing password account + # that later signs in via magic-link/Google with the same address. + await conn.execute( + "UPDATE auth_users SET email_verified = TRUE WHERE id = $1", user["id"], + ) + # Ensure profiles row exists and email is current. Seed the name from the # OAuth profile, but only when not already set (COALESCE keeps an edited # name; NULLIF treats an empty incoming value as "no name"). @@ -229,6 +238,10 @@ class MagicLinkVerifyBody(BaseModel): token: str +class VerifyEmailBody(BaseModel): + token: str + + class PasswordSignupBody(BaseModel): email: str password: str @@ -329,6 +342,48 @@ async def magic_link_verify(request: Request, body: MagicLinkVerifyBody): return _token_response(user["id"], user["email"], rt) +@router.post("/verify-email") +@limiter.limit("10/minute") +async def verify_email(request: Request, body: VerifyEmailBody): + """ + Consume an email-verification token: mark the account verified and + re-evaluate the beta/premium grant at that point. Token-authenticated, so no + session is required (the user may open the link in a fresh browser). Reuses + the magic_tokens table — same one-time, TTL'd semantics as a magic link. + """ + token = body.token.strip() + now = datetime.now(timezone.utc) + + async with _pool().acquire() as conn: + row = await conn.fetchrow( + "SELECT id, email, expires_at, used_at FROM magic_tokens WHERE token = $1", + token, + ) + if row is None: + raise HTTPException(status_code=401, detail="Invalid or expired verification link") + if row["used_at"] is not None: + raise HTTPException(status_code=401, detail="Verification link already used") + if row["expires_at"] < now: + raise HTTPException(status_code=401, detail="Verification link has expired") + + await conn.execute( + "UPDATE magic_tokens SET used_at = $1 WHERE id = $2", now, row["id"], + ) + urow = await conn.fetchrow( + "UPDATE auth_users SET email_verified = TRUE WHERE email = $1 RETURNING id", + row["email"], + ) + if urow is not None: + # Re-run ensure_profile now that email_verified is TRUE, so the + # beta/premium slot is (re)evaluated immediately rather than waiting + # for the next /me/profile call. Deferred import avoids a circular + # import (main imports this router at module load). + from main import ensure_profile # noqa: PLC0415 + await ensure_profile(conn, str(urow["id"]), row["email"]) + + return {"verified": True} + + # ── Password auth ──────────────────────────────────────────────────────────── @router.post("/password/signup", status_code=201) @@ -353,6 +408,8 @@ async def password_signup(request: Request, body: PasswordSignupBody): raise HTTPException(status_code=409, detail="Email already registered") new_id = str(uuid.uuid4()) + # email_verified defaults to FALSE (migration 032): a password account + # must confirm ownership before it can claim a beta/premium slot. await conn.execute( """ INSERT INTO auth_users (id, email, password_hash) @@ -368,8 +425,23 @@ async def password_signup(request: Request, body: PasswordSignupBody): await conn.execute( "UPDATE auth_users SET last_sign_in_at = now() WHERE id = $1", new_id ) + # Verification token (reuses the magic_tokens one-time, TTL'd table). + verify_token = secrets.token_urlsafe(32) + await conn.execute( + "INSERT INTO magic_tokens (email, token, expires_at) VALUES ($1, $2, $3)", + email, verify_token, datetime.now(timezone.utc) + _MAGIC_TTL, + ) rt = await _issue_refresh_token(conn, new_id) + # Send the verification email out of band. The account is usable immediately + # for the free instruments (which need no account at all); verification only + # unlocks the free Full Moon slot, so a send failure must not fail signup. + verify_link = f"{_FRONTEND_URL}/auth/callback?type=verify&token={verify_token}" + try: + await send_verify_email(email, verify_link) + except Exception as exc: + print(f"[auth] verification email failed for {email}: {exc}") + return _token_response(new_id, email, rt) diff --git a/api/emails.py b/api/emails.py index bf39012db..ebf66bfa8 100644 --- a/api/emails.py +++ b/api/emails.py @@ -117,6 +117,48 @@ def _lang(native_language: str | None) -> str: "da": "Hvis du ikke har anmodet om dette link, kan du roligt ignorere denne e-mail.", }, + # ── Email verification (password signup) ───────────────────────────────── + "verify_subject": { + "en": "Confirm your email — Cèrcol", + "ca": "Confirma el teu correu — Cèrcol", + "es": "Confirma tu correo — Cèrcol", + "fr": "Confirmez votre e-mail — Cèrcol", + "de": "Bestätige deine E-Mail — Cèrcol", + "da": "Bekræft din e-mail — Cèrcol", + }, + "verify_heading": { + "en": "Confirm your email", + "ca": "Confirma el teu correu", + "es": "Confirma tu correo", + "fr": "Confirmez votre e-mail", + "de": "Bestätige deine E-Mail", + "da": "Bekræft din e-mail", + }, + "verify_body": { + "en": "Confirm your email to unlock your free Full Moon assessment. The link is valid for 15 minutes.", + "ca": "Confirma el teu correu per desbloquejar la teva avaluació Lluna Plena gratuïta. L'enllaç és vàlid durant 15 minuts.", + "es": "Confirma tu correo para desbloquear tu evaluación Luna Llena gratuita. El enlace es válido durante 15 minutos.", + "fr": "Confirmez votre e-mail pour débloquer votre évaluation Pleine Lune gratuite. Le lien est valide pendant 15 minutes.", + "de": "Bestätige deine E-Mail, um deine kostenlose Vollmond-Auswertung freizuschalten. Der Link ist 15 Minuten gültig.", + "da": "Bekræft din e-mail for at låse op for din gratis Fuldmåne-vurdering. Linket er gyldigt i 15 minutter.", + }, + "verify_button": { + "en": "Confirm email", + "ca": "Confirma el correu", + "es": "Confirmar correo", + "fr": "Confirmer l'e-mail", + "de": "E-Mail bestätigen", + "da": "Bekræft e-mail", + }, + "verify_ignore": { + "en": "If you did not create a Cèrcol account, you can safely ignore this email.", + "ca": "Si no has creat un compte a Cèrcol, pots ignorar aquest correu.", + "es": "Si no has creado una cuenta en Cèrcol, puedes ignorar este correo.", + "fr": "Si vous n'avez pas créé de compte Cèrcol, vous pouvez ignorer cet e-mail.", + "de": "Falls du kein Cèrcol-Konto erstellt hast, kannst du diese E-Mail ignorieren.", + "da": "Hvis du ikke har oprettet en Cèrcol-konto, kan du roligt ignorere denne e-mail.", + }, + # ── Witness assigned ───────────────────────────────────────────────────── "wa_subject": { "en": "{subject_display} has asked you to evaluate them on Cèrcol", @@ -638,6 +680,16 @@ def _magic_link_html(link: str, lang: str) -> str: ) +def _verify_email_html(link: str, lang: str) -> str: + return _base( + _h1(_t("verify_heading", lang)) + + _p(_t("verify_body", lang)) + + _btn(link, _t("verify_button", lang)) + + _p(_t("verify_ignore", lang), muted=True), + lang=lang, + ) + + def _witness_assigned_html( witness_name: str, subject_display: str, link: str, lang: str ) -> str: @@ -708,6 +760,16 @@ async def send_magic_link(to_email: str, link: str, lang: str = "en") -> None: ) +async def send_verify_email(to_email: str, link: str, lang: str = "en") -> None: + """Send a password-signup email-verification link.""" + l = _lang(lang) + await _send( + to = to_email, + subject = _t("verify_subject", l), + html = _verify_email_html(link, l), + ) + + async def send_witness_assigned( witness_name: str, witness_email: str, diff --git a/api/main.py b/api/main.py index d1dbee35a..0e7323d56 100644 --- a/api/main.py +++ b/api/main.py @@ -343,6 +343,15 @@ def get_optional_user( BETA_TOTAL = 500 +# Grant guard: a beta/premium slot is only claimed by an account whose email is +# verified. Magic-link and Google prove ownership by construction (set TRUE at +# account creation); password accounts start FALSE and flip TRUE via +# /auth/verify-email. This closes disposable-email slot-farming on the unverified +# password-signup path. $1 is user_id in both ensure_profile branches. The +# fragment injects only static SQL (no user data), so f-string interpolation is +# safe; asyncpg still binds every value as a numbered parameter. +_EMAIL_VERIFIED_SQL = "COALESCE((SELECT email_verified FROM auth_users WHERE id = $1), FALSE)" + async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | None = None) -> None: """Create/update profile row, claim beta premium if slots remain, link pending invitations. @@ -353,24 +362,27 @@ async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | No /me/results, /results) the row already exists and the INSERT branch never wins. Without granting on conflict, the first ~500 beta users were silently left on premium = false and sent to the paywall. The grant condition mirrors - the INSERT: a slot remains AND the row is not already premium or beta. We - never revoke an existing grant or a paid premium (OR with the current value), - and a paid user is never relabelled beta (the grant requires NOT premium).""" + the INSERT: a slot remains AND the row is not already premium or beta AND the + account's email is verified (_EMAIL_VERIFIED_SQL). We never revoke an existing + grant or a paid premium (OR with the current value), and a paid user is never + relabelled beta (the grant requires NOT premium).""" if email: await conn.execute( - """ + f""" INSERT INTO profiles (id, email, premium, is_beta) SELECT $1, $2, - (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE), - (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) + ((SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}), + ((SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, premium = profiles.premium OR ( NOT profiles.premium AND NOT profiles.is_beta - AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)), + AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) + AND {_EMAIL_VERIFIED_SQL}), is_beta = profiles.is_beta OR ( NOT profiles.premium AND NOT profiles.is_beta - AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)) + AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) + AND {_EMAIL_VERIFIED_SQL}) """, user_id, email.lower(), BETA_TOTAL, ) @@ -384,18 +396,20 @@ async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | No ) else: await conn.execute( - """ + f""" INSERT INTO profiles (id, premium, is_beta) SELECT $1, - (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE), - (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) + ((SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}), + ((SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}) ON CONFLICT (id) DO UPDATE SET premium = profiles.premium OR ( NOT profiles.premium AND NOT profiles.is_beta - AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)), + AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) + AND {_EMAIL_VERIFIED_SQL}), is_beta = profiles.is_beta OR ( NOT profiles.premium AND NOT profiles.is_beta - AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)) + AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) + AND {_EMAIL_VERIFIED_SQL}) """, user_id, BETA_TOTAL, ) diff --git a/api/tests/test_beta_grant.py b/api/tests/test_beta_grant.py index 3a5ceaeac..13de9ef62 100644 --- a/api/tests/test_beta_grant.py +++ b/api/tests/test_beta_grant.py @@ -53,21 +53,46 @@ def test_conflict_branch_grants_premium_and_beta(): assert "profiles.is_beta OR" in conflict # The cap is enforced by the remaining-slots subquery. assert "COUNT(*) <" in conflict + # The grant is now gated on a verified email (migration 032). Both the fresh + # INSERT and the ON CONFLICT branch must consult auth_users.email_verified. + assert "email_verified" in upsert.split("ON CONFLICT", 1)[0] + assert "email_verified" in conflict -def _granted(premium: bool, is_beta: bool, slots_remain: bool) -> tuple[bool, bool]: - """Python mirror of the SQL grant expression for premium and is_beta.""" - grant = (not premium) and (not is_beta) and slots_remain +def test_verified_gate_present_in_both_ensure_profile_branches(): + # Email branch (above) and the no-email branch must both gate on verification. + conn = RecordingConn() + _run(main_module.ensure_profile(conn, "user-2", None)) + upsert = next(q for q in conn.executed if "INSERT INTO profiles" in q) + assert "email_verified" in upsert + assert "COUNT(*) <" in upsert # slot cap still present alongside the gate + + +def _granted(premium: bool, is_beta: bool, slots_remain: bool, verified: bool) -> tuple[bool, bool]: + """Python mirror of the SQL grant expression for premium and is_beta. + + Mirrors: NOT premium AND NOT is_beta AND slots_remain AND email_verified. + """ + grant = (not premium) and (not is_beta) and slots_remain and verified return (premium or grant, is_beta or grant) def test_grant_truth_table(): - # Defectively-denied beta user, slots remain -> granted both. - assert _granted(False, False, True) == (True, True) - # Slots exhausted -> stays paywalled. - assert _granted(False, False, False) == (False, False) + # Verified, defectively-denied beta user, slots remain -> granted both. + assert _granted(False, False, True, True) == (True, True) + # Slots exhausted -> stays paywalled even when verified. + assert _granted(False, False, False, True) == (False, False) # Already a beta user -> unchanged, never double-touched. - assert _granted(True, True, True) == (True, True) + assert _granted(True, True, True, True) == (True, True) # Paid customer (premium, not beta): keeps premium, never relabelled beta. - assert _granted(True, False, True) == (True, False) - assert _granted(True, False, False) == (True, False) + assert _granted(True, False, True, True) == (True, False) + assert _granted(True, False, False, True) == (True, False) + + +def test_unverified_never_claims_a_slot(): + # The slot-farming close: an unverified account never gets the grant, even + # with slots free. It becomes eligible only once its email is verified. + assert _granted(False, False, True, False) == (False, False) + assert _granted(False, False, True, True) == (True, True) + # Verification never revokes a paid customer's premium. + assert _granted(True, False, True, False) == (True, False) diff --git a/api/tests/test_email_verification.py b/api/tests/test_email_verification.py new file mode 100644 index 000000000..7ad4c7c56 --- /dev/null +++ b/api/tests/test_email_verification.py @@ -0,0 +1,60 @@ +""" +Email-verification gating for the beta/premium grant (migration 032). + +No database in CI, so we assert on the SQL a recording connection receives: +_find_or_create_user (the magic-link / Google path) must mark the account +email_verified = TRUE, because reaching it means ownership was proven. The +password-signup path deliberately does NOT go through _find_or_create_user, so +it stays unverified until /auth/verify-email flips the flag. +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("JWT_SECRET", "x" * 48) + +import auth as auth_module # noqa: E402 + + +class RecordingConn: + """Records execute() SQL; fetchrow() returns None so the 'new user' branch runs.""" + + def __init__(self): + self.executed: list[str] = [] + + async def execute(self, query, *args): + self.executed.append(query) + return "INSERT 0 1" + + async def fetchrow(self, query, *args): + return None + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +def test_magic_link_new_user_is_marked_verified(): + conn = RecordingConn() + _run(auth_module._find_or_create_user(conn, "person@example.com")) + assert any( + "UPDATE auth_users SET email_verified = TRUE" in q for q in conn.executed + ), "magic-link/Google account creation must set email_verified = TRUE" + + +def test_google_new_user_is_marked_verified(): + conn = RecordingConn() + _run(auth_module._find_or_create_user(conn, "person@example.com", google_id="g-123")) + assert any( + "UPDATE auth_users SET email_verified = TRUE" in q for q in conn.executed + ) + + +def test_verify_email_route_registered(): + paths = {getattr(r, "path", "") for r in auth_module.router.routes} + assert "/auth/verify-email" in paths diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md index a848aad8c..9adffdd7f 100644 --- a/docs/architecture/auth.md +++ b/docs/architecture/auth.md @@ -36,6 +36,29 @@ Tokens are single-use; reuse returns 401. - bcrypt direct (no passlib wrapper). - Signup creates a `profiles` row with the bcrypt hash. - Signin verifies and issues tokens. +- Signup creates the account **unverified** (`auth_users.email_verified = FALSE`) + and issues tokens (the account is immediately usable — the free instruments + need no account at all). A verification email is sent via Resend, reusing the + one-time `magic_tokens` table. Clicking it hits `POST /auth/verify-email`, + which consumes the token, sets `email_verified = TRUE`, and re-runs + `ensure_profile`. Rate-limited `5/minute` (signup) and `10/minute` (verify). + +## Email verification and the beta/premium grant + +The "first 500 free Full Moon" grant in `ensure_profile` (`api/main.py`) is gated +on `auth_users.email_verified` (migration `032`). An account only claims a +beta/premium slot once its email is verified, which closes disposable-email +slot-farming on the unverified password-signup path. + +- Magic link and Google OAuth prove ownership by construction: both route + through `_find_or_create_user`, which sets `email_verified = TRUE`, so they get + the grant on their first `/me/profile` call as before. +- Password accounts start `FALSE` and become eligible only after + `POST /auth/verify-email`. +- The gate lives in the grant SQL itself (`_EMAIL_VERIFIED_SQL`), so every caller + of `ensure_profile` inherits it; it never revokes an existing grant or a paid + premium (the grant still ORs with the current value). Accounts predating the + migration were backfilled `TRUE` (no revocation). ### Google OAuth diff --git a/src/locales/ca.json b/src/locales/ca.json index a176eaae5..762922a56 100644 --- a/src/locales/ca.json +++ b/src/locales/ca.json @@ -287,7 +287,7 @@ "sentHeading": "Comprova la safata d'entrada", "sentBody": "T'hem enviat un enllaç a {{email}}. Fes-hi clic per iniciar sessió.", "confirmHeading": "Confirma el teu correu", - "confirmBody": "T'hem enviat un enllaç de confirmació a {{email}}. Fes-hi clic per activar el compte.", + "confirmBody": "T'hem enviat un enllaç de confirmació a {{email}}. Fes-hi clic per desbloquejar la teva avaluació Lluna Plena gratuïta.", "sentNote": "Sense correu? Comprova la carpeta de correu brossa.", "signingIn": "Iniciant sessió…", "tryAgain": "Torna-ho a intentar", diff --git a/src/locales/da.json b/src/locales/da.json index e225ecda5..53fdbbf5b 100644 --- a/src/locales/da.json +++ b/src/locales/da.json @@ -287,7 +287,7 @@ "sentHeading": "Tjek din indbakke", "sentBody": "Vi sendte et link til {{email}}. Klik på det for at logge ind.", "confirmHeading": "Bekræft din e-mail", - "confirmBody": "Vi sendte et bekræftelseslink til {{email}}. Klik på det for at aktivere din konto.", + "confirmBody": "Vi sendte et bekræftelseslink til {{email}}. Klik på det for at låse op for din gratis Fuldmåne-vurdering.", "sentNote": "Ingen e-mail? Tjek din spam-mappe.", "signingIn": "Logger dig ind…", "tryAgain": "Prøv igen", diff --git a/src/locales/de.json b/src/locales/de.json index 654791c85..eef53bbfa 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -287,7 +287,7 @@ "sentHeading": "Überprüfe dein Postfach", "sentBody": "Wir haben einen Link an {{email}} gesendet. Klicke darauf, um dich anzumelden.", "confirmHeading": "Bestätige deine E-Mail", - "confirmBody": "Wir haben einen Bestätigungslink an {{email}} gesendet. Klicke darauf, um dein Konto zu aktivieren.", + "confirmBody": "Wir haben einen Bestätigungslink an {{email}} gesendet. Klicke darauf, um deine kostenlose Vollmond-Auswertung freizuschalten.", "sentNote": "Keine E-Mail? Überprüfe deinen Spam-Ordner.", "signingIn": "Du wirst angemeldet…", "tryAgain": "Erneut versuchen", diff --git a/src/locales/en.json b/src/locales/en.json index 5c30f2c9c..46f196c0c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -287,7 +287,7 @@ "sentHeading": "Check your inbox", "sentBody": "We sent a link to {{email}}. Click it to sign in.", "confirmHeading": "Confirm your email", - "confirmBody": "We sent a confirmation link to {{email}}. Click it to activate your account.", + "confirmBody": "We sent a confirmation link to {{email}}. Click it to unlock your free Full Moon assessment.", "sentNote": "No email? Check your spam folder.", "signingIn": "Signing you in…", "tryAgain": "Try again", diff --git a/src/locales/es.json b/src/locales/es.json index b3a63ff69..a1bdf83fa 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -287,7 +287,7 @@ "sentHeading": "Revisa tu bandeja de entrada", "sentBody": "Enviamos un enlace a {{email}}. Haz clic en él para iniciar sesión.", "confirmHeading": "Confirma tu correo electrónico", - "confirmBody": "Enviamos un enlace de confirmación a {{email}}. Haz clic en él para activar tu cuenta.", + "confirmBody": "Enviamos un enlace de confirmación a {{email}}. Haz clic en él para desbloquear tu evaluación Luna Llena gratuita.", "sentNote": "¿No recibes el correo? Revisa la carpeta de spam.", "signingIn": "Iniciando sesión…", "tryAgain": "Intentar de nuevo", diff --git a/src/locales/fr.json b/src/locales/fr.json index acedebe94..8404b04e2 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -287,7 +287,7 @@ "sentHeading": "Vérifiez votre boîte de réception", "sentBody": "Nous avons envoyé un lien à {{email}}. Cliquez dessus pour vous connecter.", "confirmHeading": "Confirmez votre e-mail", - "confirmBody": "Nous avons envoyé un lien de confirmation à {{email}}. Cliquez dessus pour activer votre compte.", + "confirmBody": "Nous avons envoyé un lien de confirmation à {{email}}. Cliquez dessus pour débloquer votre évaluation Pleine Lune gratuite.", "sentNote": "Pas d'e-mail ? Vérifiez votre dossier de courrier indésirable.", "signingIn": "Connexion en cours…", "tryAgain": "Réessayer", diff --git a/src/pages/AuthCallbackPage.jsx b/src/pages/AuthCallbackPage.jsx index 0e2ec8689..c39ce026a 100644 --- a/src/pages/AuthCallbackPage.jsx +++ b/src/pages/AuthCallbackPage.jsx @@ -7,6 +7,11 @@ * 2. Google OAuth: /auth/callback?access_token=&refresh_token= * → Backend redirects here with tokens already in the URL → store → redirect home * + * 3. Email verify: /auth/callback?type=verify&token= + * → POST /auth/verify-email → marks the account verified (unlocking the free + * Full Moon slot) → redirect to /full-moon. No tokens: the account already + * has its session from signup; this only flips email_verified. + * * On error, redirects to /auth with ?error=... query param. */ import { useEffect, useRef } from 'react' @@ -71,6 +76,33 @@ export default function AuthCallbackPage() { return } + // Case 1b: Email verification (password signup) + if (type === 'verify') { + const token = params.get('token') + if (!token) { + navigate('/auth?error=missing_token', { replace: true }) + return + } + try { + const res = await fetch(`${API_URL}/auth/verify-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + const msg = encodeURIComponent(body.detail ?? 'verify_error') + navigate(`/auth?error=${msg}`, { replace: true }) + return + } + // Verified: land on the now-unlocked Full Moon instrument. + navigate('/full-moon', { replace: true }) + } catch { + navigate('/auth?error=network_error', { replace: true }) + } + return + } + // Case 2: Google OAuth (tokens already in URL) if (accessToken && refreshToken) { await applySession(accessToken, refreshToken) diff --git a/src/pages/AuthPage.jsx b/src/pages/AuthPage.jsx index 351e58fdd..f030d2369 100644 --- a/src/pages/AuthPage.jsx +++ b/src/pages/AuthPage.jsx @@ -28,10 +28,6 @@ export default function AuthPage() { const navigate = useNavigate() const { user, signIn, signInWithPassword, signUp, signInWithGoogle } = useAuth() - useEffect(() => { - if (user) navigate('/', { replace: true }) - }, [user, navigate]) - const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [method, setMethod] = useState('password') // 'password' | 'magic' @@ -39,6 +35,14 @@ export default function AuthPage() { const [status, setStatus] = useState('idle') // 'idle' | 'busy' | 'sent' | 'confirmed' const [errorMsg, setErrorMsg] = useState('') + // Redirect home once signed in, EXCEPT right after a password signup: there we + // hold on the "check your email" card (status 'sent') so the user learns they + // must verify to unlock the free Full Moon slot. The account is already signed + // in and can use the free instruments meanwhile. + useEffect(() => { + if (user && status !== 'sent') navigate('/', { replace: true }) + }, [user, status, navigate]) + function setError(msg) { setStatus('idle'); setErrorMsg(msg) } function setBusy() { setStatus('busy'); setErrorMsg('') } @@ -60,7 +64,10 @@ export default function AuthPage() { // onAuthStateChange handles redirect; just stay busy } else { await signUp(email, password) - // Signup succeeded — access token is now set; AuthContext re-renders automatically + // Signup succeeded — the account is signed in, but its email is not yet + // verified. Hold on the confirm-email card (the redirect effect skips + // 'sent') so the user knows to click the link to unlock the free slot. + setStatus('sent') } } catch (e) { setError(e.message ?? t('auth.error'))