From f7a65c2effd0bd9a50a902300e49740ed46fd908 Mon Sep 17 00:00:00 2001 From: Arya Venkatesan Date: Fri, 31 Jul 2026 13:18:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20suite=2039=20=E2=80=94=20password?= =?UTF-8?q?-reset=20flow=20+=20fix=20BetterAuthAccount=20uuid=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39.1 — Wire the Better Auth password-reset flow so passwordless-account rollover (suite 38.A4) is no longer a dead end: - Add emailAndPassword.sendResetPassword in theCommonsWeb/src/lib/auth.ts, sending the reset email directly via the Brevo REST API from the Next server (the frontend has no shared email helper). Fails silently/logged, never throws (anti-enumeration). - Rewire /forgot-password to call authClient.requestPasswordReset instead of a stub. - New public /reset-password?token= page consuming the token via resetPassword. - Requires BREVO_API_KEY (+ optional DIGEST_FROM_EMAIL) in theCommonsWeb PROD env — without it, /forgot-password reports success but no email is sent. 39.2 — Fix latent auth model/schema drift found in 38.A4: - BetterAuthAccount.user_id TextField -> UUIDField (db_column="userId") to match the live neon_auth.account column; ORM anti-joins no longer raise uuid=text. managed=False mirror, so no migration is generated. Added a fast field-type assertion test. Backend 115 tests OK, makemigrations clean; frontend build + 33 tests + lint green. Co-Authored-By: Claude Opus 4.8 --- .../rollover_passwordless_accounts.py | 26 ++-- backendServer/events/models.py | 2 +- .../events/tests/test_config_fast.py | 18 +++ theCommonsWeb/.env.example | 10 ++ .../forgot-password/ForgotPasswordForm.tsx | 43 +++--- .../app/reset-password/ResetPasswordForm.tsx | 140 ++++++++++++++++++ theCommonsWeb/src/app/reset-password/page.tsx | 10 ++ theCommonsWeb/src/lib/auth-client.ts | 10 +- theCommonsWeb/src/lib/auth.ts | 47 +++++- 9 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 theCommonsWeb/src/app/reset-password/ResetPasswordForm.tsx create mode 100644 theCommonsWeb/src/app/reset-password/page.tsx diff --git a/backendServer/events/management/commands/rollover_passwordless_accounts.py b/backendServer/events/management/commands/rollover_passwordless_accounts.py index 907387d..2510dd1 100644 --- a/backendServer/events/management/commands/rollover_passwordless_accounts.py +++ b/backendServer/events/management/commands/rollover_passwordless_accounts.py @@ -75,12 +75,10 @@ def _email_text(name: str) -> str: def find_affected_users() -> list[dict]: """Return neon_auth users with no usable credential-provider password. - Raw SQL, not the ORM, and for a more concrete reason than "it's a mirror - table": BetterAuthAccount.user_id (events/models.py) is declared as a - Django TextField, but the live `neon_auth.account."userId"` column is - actually `uuid` (confirmed via information_schema.columns against the - test DB — the model's type annotation has drifted from the schema it - mirrors). An ORM anti-join such as + Raw SQL, not the ORM. This originally sidestepped a real drift bug: + BetterAuthAccount.user_id (events/models.py) was declared as a Django + TextField while the live `neon_auth.account."userId"` column is actually + `uuid`, so an ORM anti-join such as BetterAuthUser.objects.exclude( id__in=BetterAuthAccount.objects.filter( @@ -88,14 +86,14 @@ def find_affected_users() -> list[dict]: ).values("user_id") ) - asks Django to bind the subquery's "userId" values as text (per the - model field) against `u.id` (uuid), and Postgres has no `uuid = text` - operator for a subquery comparison — it raises - `operator does not exist: uuid = text` at query time (reproduced while - building this command). Raw SQL sidesteps the model's stale type - entirely: both columns really are `uuid`, so a plain LEFT JOIN with no - cast is correct. Both mirror tables are managed=False; this is a - read-only SELECT, never a migration. + raised `operator does not exist: uuid = text` at query time (reproduced + while building this command). That's fixed now (39.2) — user_id is a + UUIDField — so the ORM anti-join above would work today. This function + still uses raw SQL rather than switching to it, because `neon_auth` mirror + tables are excluded from the test DB (backend/settings/test.py), so there + is no way to exercise an ORM version of this query in this environment. + Both mirror tables are managed=False; this is a read-only SELECT, never + a migration. """ query = """ SELECT u.id, u.email, u.name diff --git a/backendServer/events/models.py b/backendServer/events/models.py index 4008ed9..947ee16 100644 --- a/backendServer/events/models.py +++ b/backendServer/events/models.py @@ -79,7 +79,7 @@ class BetterAuthAccount(models.Model): id = models.TextField(primary_key=True) account_id = models.TextField(db_column="accountId") provider_id = models.TextField(db_column="providerId") - user_id = models.TextField(db_column="userId") + user_id = models.UUIDField(db_column="userId") access_token = models.TextField(db_column="accessToken", null=True, blank=True) # noqa: DJ001 # mirrors neon_auth schema refresh_token = models.TextField(db_column="refreshToken", null=True, blank=True) # noqa: DJ001 # mirrors neon_auth schema id_token = models.TextField(db_column="idToken", null=True, blank=True) # noqa: DJ001 # mirrors neon_auth schema diff --git a/backendServer/events/tests/test_config_fast.py b/backendServer/events/tests/test_config_fast.py index c5fb184..d572e45 100644 --- a/backendServer/events/tests/test_config_fast.py +++ b/backendServer/events/tests/test_config_fast.py @@ -14,6 +14,7 @@ from backend.settings import select_settings_env from events.management.commands.healthcheck import FAIL, OK, Command +from events.models import BetterAuthAccount @tag("fast") @@ -69,3 +70,20 @@ def test_prod_fails_on_empty_allowed_hosts(self): def test_dev_run_never_fails(self): status, name, _ = self._probe(require_prod=False) self.assertEqual((status, name), (OK, "config")) + + +@tag("fast") +class BetterAuthAccountUserIdFieldTests(unittest.TestCase): + """Guardrail for 39.2: neon_auth.account."userId" is `uuid`, not `text`. + + An ORM anti-join against a stale TextField mirror raises + `operator does not exist: uuid = text` at query time. This asserts the + field declaration matches the real schema without touching the DB — + `neon_auth` mirrors aren't built in the test DB (see + backend/settings/test.py), so this can't be a `_db` query test. + """ + + def test_user_id_is_uuid_field_with_matching_db_column(self): + field = BetterAuthAccount._meta.get_field("user_id") + self.assertEqual(field.get_internal_type(), "UUIDField") + self.assertEqual(field.db_column, "userId") diff --git a/theCommonsWeb/.env.example b/theCommonsWeb/.env.example index d1b961e..c80697d 100644 --- a/theCommonsWeb/.env.example +++ b/theCommonsWeb/.env.example @@ -31,3 +31,13 @@ NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 # origins to trust (beyond the five hardcoded in src/lib/auth.ts). Also read # natively by better-auth itself, so no code changes are needed to add more. # BETTER_AUTH_TRUSTED_ORIGINS= + +# BREVO_API_KEY / DIGEST_FROM_EMAIL — required in prod for the password-reset +# email (emailAndPassword.sendResetPassword in src/lib/auth.ts). Same Brevo +# account/sender as backendServer's email_service.py, called directly via the +# Brevo REST API since there's no shared email helper across the two apps. +# Without BREVO_API_KEY set, /forgot-password still responds success (Better +# Auth's anti-enumeration behavior) but no email actually goes out — logged, +# not thrown. +BREVO_API_KEY= +DIGEST_FROM_EMAIL=digest@thecommons.town diff --git a/theCommonsWeb/src/app/(portal)/forgot-password/ForgotPasswordForm.tsx b/theCommonsWeb/src/app/(portal)/forgot-password/ForgotPasswordForm.tsx index 64c0ab5..050ca9d 100644 --- a/theCommonsWeb/src/app/(portal)/forgot-password/ForgotPasswordForm.tsx +++ b/theCommonsWeb/src/app/(portal)/forgot-password/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; +import { authClient } from '../../../lib/auth-client'; import { Input } from '../../../components/ui/Input'; import { Button } from '../../../components/ui/Button'; import { PortalShell } from '../PortalShell'; @@ -17,30 +18,31 @@ export function ForgotPasswordForm() { const [email, setEmail] = useState(''); const [submitted, setSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setSubmitted(true); + setIsLoading(true); + try { + await authClient.requestPasswordReset({ + email: email.trim(), + redirectTo: '/reset-password', + }); + } catch { + // Swallow — the confirmation message stays neutral either way so + // we never reveal whether an account exists for this email. + } finally { + setIsLoading(false); + setSubmitted(true); + } } return (

- The Commons doesn’t send password reset emails. In most cases you - don’t need one — just enter your email on the Sign In page and - continue without a password. -

- -

- If your account has a password you can’t recover, contact{' '} - - The Commons - {' '} - for help. + Enter your account email and we’ll send you a link to set a new + password.

@@ -48,6 +50,8 @@ export function ForgotPasswordForm() { label="Email" type="email" autoComplete="email" + required + autoFocus value={email} onChange={e => setEmail(e.target.value)} placeholder="you@example.com" @@ -58,14 +62,13 @@ export function ForgotPasswordForm() { className="p-2 border-2 border-[var(--color-accent)] text-[var(--color-accent)] text-sm font-bold" role="status" > - There’s no password to reset — head to Sign In and enter - this email to continue without one. + If that email exists in our system, we’ve sent a reset link.
)}
- (null); + const [isLoading, setIsLoading] = useState(false); + const [done, setDone] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!token) { + setError('This reset link is missing its token. Request a new one from the forgot password page.'); + return; + } + if (password.length < MIN_PASSWORD_LENGTH) { + setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + return; + } + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + + setIsLoading(true); + try { + const { error: resetError } = await authClient.resetPassword({ + newPassword: password, + token, + }); + if (resetError) { + throw new Error(resetError.message ?? 'This reset link is invalid or has expired.'); + } + setDone(true); + window.location.href = '/signin'; + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'This reset link is invalid or has expired. Request a new one from the forgot password page.', + ); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+

+ Reset Password +

+

+ Choose a new password for your account. +

+
+ + {!token ? ( +
+

Missing reset link.

+

+ Use the link from your password reset email, or request a new one below. +

+ + ← Request a Reset Link + +
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} + {done && ( +
+ Password updated. Redirecting to sign in… +
+ )} + + + setPassword(e.target.value)} + /> + setConfirm(e.target.value)} + /> +
+ +
+ + + )} +
+ ); +} diff --git a/theCommonsWeb/src/app/reset-password/page.tsx b/theCommonsWeb/src/app/reset-password/page.tsx new file mode 100644 index 0000000..b463f8e --- /dev/null +++ b/theCommonsWeb/src/app/reset-password/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { ResetPasswordForm } from './ResetPasswordForm'; + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/theCommonsWeb/src/lib/auth-client.ts b/theCommonsWeb/src/lib/auth-client.ts index dc3fb4d..e265e47 100644 --- a/theCommonsWeb/src/lib/auth-client.ts +++ b/theCommonsWeb/src/lib/auth-client.ts @@ -7,4 +7,12 @@ export const authClient = createAuthClient({ plugins: [inferAdditionalFields()], }); -export const { signIn, signUp, signOut, useSession, getSession } = authClient; +export const { + signIn, + signUp, + signOut, + useSession, + getSession, + requestPasswordReset, + resetPassword, +} = authClient; diff --git a/theCommonsWeb/src/lib/auth.ts b/theCommonsWeb/src/lib/auth.ts index 5ec6505..5556c59 100644 --- a/theCommonsWeb/src/lib/auth.ts +++ b/theCommonsWeb/src/lib/auth.ts @@ -54,7 +54,52 @@ export const auth = betterAuth({ } : {}), }, - emailAndPassword: { enabled: true, autoSignIn: true }, + emailAndPassword: { + enabled: true, + autoSignIn: true, + // Suite 39: Suite 38 made accounts password-required and removed + // passwordless sign-in, stranding pre-existing passwordless accounts. + // This is the rollover path back to a usable account. Sent directly + // via Brevo's transactional API (the same provider events/email_service.py + // uses on the backend) — there's no frontend email-send helper to reuse. + // Best-effort: a Brevo failure here must not surface as a request error, + // since Better Auth already responds with a neutral "check your email" + // message regardless of whether the account exists. + sendResetPassword: async ({ user, url }) => { + const apiKey = process.env.BREVO_API_KEY; + if (!apiKey) { + console.error('[auth] sendResetPassword: BREVO_API_KEY is not set — cannot send email'); + return; + } + try { + const res = await fetch('https://api.brevo.com/v3/smtp/email', { + method: 'POST', + headers: { + 'api-key': apiKey, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + sender: { + name: 'The Commons', + email: process.env.DIGEST_FROM_EMAIL ?? 'digest@thecommons.town', + }, + to: [{ email: user.email }], + subject: 'Reset your Commons password', + htmlContent: + '

Someone requested a password reset for your Commons account.

' + + `

${url}

` + + '

If you did not request this, you can safely ignore this email — ' + + 'your password will not change.

', + }), + }); + if (!res.ok) { + console.error('[auth] sendResetPassword: Brevo send failed', res.status, await res.text()); + } + } catch (err) { + console.error('[auth] sendResetPassword: Brevo send threw', err); + } + }, + }, // Google sign-in temporarily disabled — revisit later. It returned // `invalid_code` and bypassed user-type selection during signup. // socialProviders: {