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
View File
@@ -14,6 +14,7 @@
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"better-auth": "1.6.25",
"clsx": "^2.1.1",
"html5-qrcode": "^2.3.8",
"next": "^14.2.4",
@@ -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>
);
}
+2 -8
View File
@@ -107,11 +107,7 @@ export default function AdminEmailsPage() {
const loadEvents = async () => {
try {
const res = await fetch('/api/events', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
const res = await fetch('/api/events', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
@@ -169,9 +165,7 @@ export default function AdminEmailsPage() {
try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json();
+1 -5
View File
@@ -35,11 +35,7 @@ export default function AdminGalleryPage() {
const loadMedia = async () => {
try {
// We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
const res = await fetch('/api/media', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
setMedia(data.media || []);
+104 -142
View File
@@ -1,8 +1,8 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
import { authClient } from '@/lib/auth-client';
import { fetchApi } from '@/lib/api/client';
interface User {
id: string;
@@ -18,18 +18,19 @@ interface User {
interface AuthContextType {
user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<User | null>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void;
refreshUser: () => Promise<void>;
setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<User | null>;
}
interface RegisterData {
@@ -42,178 +43,139 @@ interface RegisterData {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
const AUTH_COOKIE = 'spanglish-auth';
// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const LEGACY_TOKEN_KEY = 'spanglish-token';
const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
function setAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
function mapSessionUser(sessionUser: any): User {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
}
function clearAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
return error?.message || fallback;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (!currentToken) return;
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json',
},
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
const { data } = await authClient.getSession();
if (data?.user) {
const mapped = mapSessionUser(data.user);
setUser(mapped);
return mapped;
}
setUser(null);
return null;
} catch (error) {
// Network error, keep using cached data
// Network error: keep current state
console.error('Failed to refresh user data:', error);
return null;
}
}, []);
useEffect(() => {
// Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
let parsedUser: User | null = null;
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
// One-time cleanup of legacy JWT-era storage
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} catch {
/* SSR / storage unavailable */
}
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
const { error } = await authClient.signIn.email({ email, password });
if (error) {
throw new Error(messageFrom(error, 'Login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithGoogle = async (credential: string) => {
const res = await fetch(`${API_BASE}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
const { error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Google login failed');
if (error) {
throw new Error(messageFrom(error, 'Google login failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const loginWithMagicLink = async (magicToken: string) => {
const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
if (error) {
throw new Error(messageFrom(error, 'Invalid or expired link'));
}
const data = await res.json();
setAuthData(data);
return refreshUser();
};
const register = async (registerData: RegisterData) => {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerData),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Registration failed');
const { error } = await authClient.signUp.email({
email: registerData.email,
password: registerData.password,
name: registerData.name,
phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
} as any);
if (error) {
// Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
}
const data = await res.json();
setAuthData(data);
await refreshUser();
};
const logout = useCallback(() => {
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
// Best-effort server-side revocation; local state clears regardless.
authClient.signOut().catch(() => {
/* ignore network errors */
});
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
}, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
@@ -221,16 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAdmin,
value={{
user,
token: null,
isLoading,
isAdmin,
hasAdminAccess,
login,
login,
loginWithGoogle,
loginWithMagicLink,
register,
register,
logout,
updateUser,
setAuthData,
+79 -45
View File
@@ -1,63 +1,97 @@
import { authClient } from '../auth-client';
import { fetchApi } from './client';
import type { User } from './types';
// Thin wrappers over the Better Auth client, preserving the legacy authApi
// call surface used by the auth pages.
type ClientError = { message?: string; code?: string; status: number } | null;
function throwIfError(error: ClientError, fallback: string): void {
if (error) {
const err = new Error(error.message || fallback);
(err as any).code = error.code;
(err as any).status = error.status;
throw err;
}
}
export const authApi = {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Magic link login. Enumeration-safe UX parity: unknown emails resolve with
// the same generic message (magic links never create accounts server-side);
// only rate limiting surfaces as an error.
requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => {
const { error } = await authClient.signIn.magicLink({ email, callbackURL });
if (error && error.status === 429) {
throwIfError(error, 'Too many requests. Please try again later.');
}
return { message: 'If an account exists with this email, a login link has been sent.' };
},
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
verifyMagicLink: async (token: string) => {
const { data, error } = await authClient.magicLink.verify({ query: { token } });
throwIfError(error, 'Invalid or expired token');
return data;
},
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
// Password reset (Better Auth is enumeration-safe here by default)
requestPasswordReset: async (email: string) => {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: '/auth/reset-password',
});
throwIfError(error, 'Failed to request password reset');
return { message: 'If an account exists with this email, a password reset link has been sent.' };
},
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
confirmPasswordReset: async (token: string, password: string) => {
const { error } = await authClient.resetPassword({ newPassword: password, token });
throwIfError(error, 'Invalid or expired token');
return { message: 'Password reset successfully. Please log in with your new password.' };
},
// Account claiming
// Account claiming: a magic link that lands on the claim page, where the
// session-holding user sets a password via /api/auth-ext/claim-account.
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
authApi.requestMagicLink(email, '/auth/claim-account'),
confirmClaimAccount: (password: string) =>
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
method: 'POST',
body: JSON.stringify({ email }),
body: JSON.stringify({ password }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
claimEligibility: (email: string) =>
fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
),
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Google Identity Services credential (ID token) sign-in
googleAuth: async (credential: string) => {
const { data, error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
throwIfError(error, 'Google login failed');
return data;
},
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Change password; other sessions are revoked so a stolen session can't
// outlive the change (this device stays signed in).
changePassword: async (currentPassword: string, newPassword: string) => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
throwIfError(error, 'Failed to change password');
return { message: 'Password changed successfully' };
},
// Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'),
me: async (): Promise<{ user: User | null }> => {
const { data, error } = await authClient.getSession();
throwIfError(error, 'Failed to load session');
return { user: (data?.user as unknown as User) ?? null };
},
};
+7 -16
View File
@@ -1,30 +1,25 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
// Auth rides on the Better Auth httpOnly session cookie. With API_BASE unset
// (same-origin via Next rewrites) 'same-origin' sends it; a cross-origin
// API_BASE needs 'include' plus CORS credentials on the backend.
const CREDENTIALS: RequestCredentials = API_BASE ? 'include' : 'same-origin';
export interface ApiError {
error: string;
}
/** Read the stored auth token (browser only). */
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
credentials: CREDENTIALS,
...options,
headers,
});
@@ -53,11 +48,7 @@ export async function fetchBlob(
endpoint: string,
fallbackFilename: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
const res = await fetch(`${API_BASE}${endpoint}`, { credentials: CREDENTIALS });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
+3 -4
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client';
import { fetchApi, API_BASE } from './client';
import type { Media } from './types';
export const mediaApi = {
@@ -11,16 +11,15 @@ export const mediaApi = {
},
upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = getToken();
const formData = new FormData();
formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType);
// Auth rides on the session cookie
const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
credentials: API_BASE ? 'include' : 'same-origin',
body: formData,
});
+6 -5
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE, getToken } from './client';
import { fetchApi, API_BASE } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod).
@@ -94,13 +94,13 @@ export const photosApi = {
}),
uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData();
formData.append('files', file);
// Auth rides on the session cookie (sent same-origin automatically)
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: API_BASE ? 'include' : 'same-origin',
body: formData,
});
if (!res.ok) {
@@ -122,8 +122,9 @@ export const photosApi = {
new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
// Auth rides on the session cookie; XHR sends same-origin cookies by
// default, withCredentials is only needed for a cross-origin API_BASE.
if (API_BASE) xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
};
+3
View File
@@ -439,6 +439,9 @@ export interface UserSession {
ipAddress?: string;
lastActiveAt: string;
createdAt: string;
expiresAt?: string;
/** True for the session backing the current request. */
current?: boolean;
}
export interface DashboardSummary {
+22
View File
@@ -0,0 +1,22 @@
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient, adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
// Better Auth client. Sessions are httpOnly cookies set by the backend; with
// NEXT_PUBLIC_API_URL unset everything is same-origin through the Next.js
// rewrites (see next.config.js), so cookies flow automatically.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || '',
plugins: [
magicLinkClient(),
adminClient(),
inferAdditionalFields({
user: {
phone: { type: 'string', required: false },
languagePreference: { type: 'string', required: false },
rucNumber: { type: 'string', required: false },
isClaimed: { type: 'boolean', required: false },
accountStatus: { type: 'string', required: false },
},
}),
],
});
+13 -5
View File
@@ -3,16 +3,24 @@ import { NextRequest, NextResponse } from 'next/server';
/**
* Defense-in-depth guard for authenticated areas.
*
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate;
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell.
* Auth is a Better Auth httpOnly session cookie, which IS visible to this
* server-side middleware (unlike client JS). The API remains the authoritative
* gate cookie presence is not validated here; this only keeps clearly
* unauthenticated visitors from loading the admin/dashboard JS shell.
*/
const SESSION_COOKIES = [
'__Secure-spanglish.session_token', // production (useSecureCookies)
'spanglish.session_token', // development
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1';
if (!hasAuthCookie) {
const hasSessionCookie = SESSION_COOKIES.some(
(name) => !!request.cookies.get(name)?.value
);
if (!hasSessionCookie) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);