Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,27 +75,25 @@ 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(
provider_id="credential", password__isnull=False,
).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
Expand Down
2 changes: 1 addition & 1 deletion backendServer/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions backendServer/events/tests/test_config_fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
10 changes: 10 additions & 0 deletions theCommonsWeb/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,37 +18,40 @@ 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 (
<PortalShell heading="Forgot Password?">
<div className="space-y-6">
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">
The Commons doesn&rsquo;t send password reset emails. In most cases you
don&rsquo;t need one — just enter your email on the Sign In page and
continue without a password.
</p>

<p className="text-sm text-[var(--color-text-muted)] leading-relaxed">
If your account has a password you can&rsquo;t recover, contact{' '}
<a
href="mailto:aryav@unc.edu"
className="underline hover:text-[var(--color-accent)]"
>
The Commons
</a>{' '}
for help.
Enter your account email and we&rsquo;ll send you a link to set a new
password.
</p>

<form onSubmit={handleSubmit} className="space-y-4 pt-2 border-t border-[var(--color-border)]">
<Input
label="Email"
type="email"
autoComplete="email"
required
autoFocus
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
Expand All @@ -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&rsquo;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&rsquo;ve sent a reset link.
</div>
)}

<div className="flex justify-between items-center pt-2">
<Button type="submit" variant="secondary" size="sm">
Check My Options
<Button type="submit" variant="secondary" size="sm" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send Reset Link'}
</Button>
<Link
href={signInHref}
Expand Down
140 changes: 140 additions & 0 deletions theCommonsWeb/src/app/reset-password/ResetPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use client';

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';

const MIN_PASSWORD_LENGTH = 8;

// Public, login-free page reached via the reset link in the password-reset
// email (see sendResetPassword in lib/auth.ts). The token in the query
// string IS the credential — no session/auth gate here.
export function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get('token');

const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(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 (
<main id="main-content" className="max-w-[480px] mx-auto px-4 py-12">
<header className="mb-8 border-b-2 border-[var(--color-border)] pb-4">
<h1
className="font-black tracking-tight leading-none mb-1"
style={{ fontSize: 'clamp(2rem, 5vw, 3rem)', fontFamily: 'var(--font-headline)' }}
>
Reset Password
</h1>
<p className="text-sm italic text-[var(--color-text-muted)]">
Choose a new password for your account.
</p>
</header>

{!token ? (
<div className="border-2 border-[var(--color-border)] p-6 text-center">
<p className="font-bold mb-2">Missing reset link.</p>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
Use the link from your password reset email, or request a new one below.
</p>
<Link
href="/forgot-password"
className="text-xs uppercase tracking-wider font-bold hover:text-[var(--color-accent)] transition-colors"
>
&larr; Request a Reset Link
</Link>
</div>
) : (
<>
{error && (
<div
className="mb-6 p-2 border-2 border-[var(--color-accent)] text-[var(--color-accent)] text-sm font-bold"
role="alert"
>
{error}
</div>
)}
{done && (
<div
className="mb-6 p-2 border border-[var(--color-border)] bg-[var(--color-bg-alt)] text-sm"
role="status"
>
Password updated. Redirecting to sign in&hellip;
</div>
)}

<form onSubmit={handleSubmit} className="space-y-6">
<Input
label="New Password"
type="password"
autoComplete="new-password"
required
autoFocus
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<Input
label="Confirm New Password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={confirm}
onChange={e => setConfirm(e.target.value)}
/>
<div className="flex justify-end items-center pt-2">
<Button type="submit" variant="primary" disabled={isLoading}>
{isLoading ? 'Please wait…' : 'Set New Password'}
</Button>
</div>
</form>
</>
)}
</main>
);
}
10 changes: 10 additions & 0 deletions theCommonsWeb/src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Suspense } from 'react';
import { ResetPasswordForm } from './ResetPasswordForm';

export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
10 changes: 9 additions & 1 deletion theCommonsWeb/src/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,12 @@ export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()],
});

export const { signIn, signUp, signOut, useSession, getSession } = authClient;
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
requestPasswordReset,
resetPassword,
} = authClient;
47 changes: 46 additions & 1 deletion theCommonsWeb/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
'<p>Someone requested a password reset for your Commons account.</p>' +
`<p><a href="${url}">${url}</a></p>` +
'<p>If you did not request this, you can safely ignore this email — ' +
'your password will not change.</p>',
}),
});
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: {
Expand Down
Loading