Files
Spanglish/frontend/src/app/(public)/auth/magic-link/page.tsx
T
MichilisandClaude Opus 4.6 dafa3711f8 Fix post-login redirect when the session cookie is off-origin
Signing in showed the "Welcome back!" toast but never left /login. The
session cookie was host-only on the API subdomain, so the Next middleware
guard on the site origin saw no cookie and bounced /dashboard straight
back to /login?redirect=/dashboard.

- Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so
  the cookie also reaches the site origin. Unset in dev, where localhost
  is single-host and must stay host-only.
- Navigate after authentication with a full page load, via a shared
  authRedirect helper: only a top-level request carries the httpOnly
  cookie. Used by the login, register, magic-link and Google flows.
- Show "Redirecting..." on the login and register pages and keep the
  submit button disabled until the browser replaces the page, instead of
  re-enabling it mid-navigation.
- Guard against a redirect loop with a sessionStorage marker. A React ref
  cannot do this: the full page load resets component state. If the
  destination bounces back, explain it rather than navigating again.
- Middleware: accept any *.session_token cookie so a cookiePrefix change
  cannot lock everyone out, and preserve the destination's query string.
- Trust any loopback port in dev, so reaching the dev server through a
  forwarded port does not fail Better Auth's CSRF origin check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-29 20:45:05 +00:00

129 lines
4.8 KiB
TypeScript

'use client';
import { useEffect, useState, Suspense, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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 { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function MagicLinkContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { loginWithMagicLink } = useAuth();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState('');
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();
} else {
setStatus('error');
setError(language === 'es' ? 'Token no encontrado' : 'Token not found');
}
}, [token]);
const verifyToken = async () => {
try {
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(() => {
redirectAfterAuth(destination);
}, 1500);
} catch (err: any) {
setStatus('error');
setError(err.message || (language === 'es' ? 'Enlace inválido o expirado' : 'Invalid or expired link'));
}
};
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
{status === 'loading' && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue mx-auto mb-4"></div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Verificando...' : 'Verifying...'}
</h2>
<p className="text-gray-600">
{language === 'es' ? 'Por favor espera' : 'Please wait'}
</p>
</>
)}
{status === 'success' && (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? '¡Inicio de sesión exitoso!' : 'Login successful!'}
</h2>
<p className="text-gray-600">
{language === 'es' ? 'Redirigiendo...' : 'Redirecting...'}
</p>
</>
)}
{status === 'error' && (
<>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Error de Verificación' : 'Verification Error'}
</h2>
<p className="text-gray-600 mb-6">{error}</p>
<Button onClick={() => router.push('/login')}>
{language === 'es' ? 'Ir a Iniciar Sesión' : 'Go to Login'}
</Button>
</>
)}
</Card>
</div>
</div>
</div>
);
}
function LoadingFallback() {
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>
);
}
export default function MagicLinkPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<MagicLinkContent />
</Suspense>
);
}