Migrate authentication to Better Auth

Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-29 19:07:04 +00:00
co-authored by Claude Opus 5
parent 4afa5d6fa0
commit 733d2459df
47 changed files with 2430 additions and 1585 deletions
@@ -1,7 +1,7 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import { authClient } from '@/lib/auth-client';
import toast from 'react-hot-toast';
/**
* Progressive-account claim. The claim email contains a magic link that signs
* the user in (via /auth/magic-link) and redirects here; this page then asks
* for a password and completes the claim against /api/auth-ext/claim-account.
*/
function ClaimAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { setAuthData } = useAuth();
const { refreshUser } = useAuth();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const token = searchParams.get('token');
useEffect(() => {
let cancelled = false;
authClient
.getSession()
.then(({ data }) => {
if (cancelled) return;
const user: any = data?.user;
setHasSession(!!user);
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
setLoading(true);
try {
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
setAuthData({ user: result.user, token: result.token });
await authApi.confirmClaimAccount(formData.password);
await refreshUser();
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard');
} catch (error: any) {
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
}
};
if (!token) {
if (checking) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
if (alreadyClaimed) {
// Signed-in and already active: nothing to claim
router.push('/dashboard');
return null;
}
if (!hasSession) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired.'}
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
</p>
<Link href="/login">
<Button>
@@ -127,7 +160,7 @@ function ClaimAccountContent() {
<p className="text-xs text-gray-500 -mt-4">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
@@ -136,7 +169,7 @@ function ClaimAccountContent() {
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
</Button>
@@ -6,6 +6,7 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { safeInternalPath } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
function MagicLinkContent() {
@@ -18,11 +19,12 @@ function MagicLinkContent() {
const verificationAttempted = useRef(false);
const token = searchParams.get('token');
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
if (verificationAttempted.current) return;
if (token) {
verificationAttempted.current = true;
verifyToken();
@@ -34,11 +36,17 @@ function MagicLinkContent() {
const verifyToken = async () => {
try {
await loginWithMagicLink(token!);
const user = await loginWithMagicLink(token!);
setStatus('success');
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
// Unclaimed accounts must finish the claim (set a password) before the
// rest of the API will accept their session.
const destination =
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
? '/auth/claim-account'
: callbackURL;
setTimeout(() => {
router.push('/dashboard');
router.push(destination);
}, 1500);
} catch (err: any) {
setStatus('error');
@@ -25,7 +25,7 @@ interface AccountTabProps {
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser, logout } = useAuth();
const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
@@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
@@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
@@ -494,7 +496,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index === 0 ? (
{session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
+5 -3
View File
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
@@ -36,11 +36,13 @@ export default function DashboardPage() {
router.push('/login');
return;
}
if (user && token) {
// Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
loadDashboardData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading, token]);
}, [user, authLoading]);
const loadDashboardData = async () => {
setLoading(true);
@@ -48,7 +48,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// viewer's own auth token attached.
useEffect(() => {
if (initial) return;
if (authLoading) return; // wait so the Bearer token is available
if (authLoading) return; // wait until the session state has resolved
let cancelled = false;
setLoading(true);
const fetcher = eventSlug
@@ -354,6 +354,45 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
/>
)}
</div>
{/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate
pages use. */}
<div className="bg-brand-navy">
<div className="container-page px-4 py-12 md:py-16 text-center">
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
{user
? es
? '¿Listo para lo que sigue?'
: 'Ready for whats next?'
: es
? '¿Te gustó lo que viste?'
: 'Liked what you saw?'}
</h2>
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
{user
? es
? 'Revisa tus entradas y próximos eventos en tu panel.'
: 'Check your tickets and upcoming events from your dashboard.'
: es
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
: 'Join our next event and be part of the next set of photos.'}
</p>
<div className="mt-6">
<Link href={user ? '/dashboard' : '/next'}>
<Button size="lg">
{user
? es
? 'Ir a mi panel'
: 'Go to dashboard'
: es
? 'Únete al próximo evento'
: 'Join the next event'}
</Button>
</Link>
</div>
</div>
</div>
</div>
);
}