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>
129 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|