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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
733d2459df
commit
dafa3711f8
@@ -103,6 +103,7 @@ Key settings (see `backend/.env.example` for the full list):
|
|||||||
|
|
||||||
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
||||||
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
||||||
|
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
|
||||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||||
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
||||||
@@ -124,8 +125,10 @@ Key settings (see `frontend/.env.example`):
|
|||||||
|
|
||||||
- **Server port**: `PORT=3002`
|
- **Server port**: `PORT=3002`
|
||||||
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
||||||
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
|
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
|
||||||
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
|
- Inlined at build time: changing it requires a rebuild, not just a restart.
|
||||||
|
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
|
||||||
|
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
|
||||||
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ BETTER_AUTH_SECRET=
|
|||||||
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
|
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
|
||||||
BETTER_AUTH_URL=
|
BETTER_AUTH_URL=
|
||||||
|
|
||||||
|
# Session cookie domain, shared across subdomains. Set this when the API is served
|
||||||
|
# from a different host than the site (e.g. api.spanglishcommunity.com vs
|
||||||
|
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
|
||||||
|
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
|
||||||
|
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
|
||||||
|
# E.g. .spanglishcommunity.com
|
||||||
|
AUTH_COOKIE_DOMAIN=
|
||||||
|
|
||||||
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
|
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
|
||||||
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
|
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
|
||||||
# trusted; anything else is treated as a client and rate-limited by its
|
# trusted; anything else is treated as a client and rate-limited by its
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import { getLoginLockout } from './stores/loginLockout.js';
|
|||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||||
|
|
||||||
|
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
|
||||||
|
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
|
||||||
|
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
|
||||||
|
// and needs a host-only cookie.
|
||||||
|
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||||
|
|
||||||
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
||||||
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
@@ -48,6 +54,15 @@ function computeTrustedOrigins(): string[] {
|
|||||||
/* keep frontendUrl as-is */
|
/* keep frontendUrl as-is */
|
||||||
}
|
}
|
||||||
if (process.env.API_URL) origins.add(process.env.API_URL);
|
if (process.env.API_URL) origins.add(process.env.API_URL);
|
||||||
|
if (!isProduction) {
|
||||||
|
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
|
||||||
|
// port forwarding), so the browser's Origin is http://localhost:<random> and every
|
||||||
|
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
|
||||||
|
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
|
||||||
|
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
|
||||||
|
origins.add('http://localhost:*');
|
||||||
|
origins.add('http://127.0.0.1:*');
|
||||||
|
}
|
||||||
return [...origins];
|
return [...origins];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +114,12 @@ export const auth = betterAuth({
|
|||||||
advanced: {
|
advanced: {
|
||||||
cookiePrefix: 'spanglish',
|
cookiePrefix: 'spanglish',
|
||||||
useSecureCookies: isProduction,
|
useSecureCookies: isProduction,
|
||||||
|
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
|
||||||
|
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
|
||||||
|
// stay valid.
|
||||||
|
...(cookieDomain
|
||||||
|
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
|
||||||
|
: {}),
|
||||||
ipAddress: {
|
ipAddress: {
|
||||||
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
||||||
// anchors trust in the TCP peer address (only our own proxies may speak
|
// anchors trust in the TCP peer address (only our own proxies may speak
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||||
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function MagicLinkContent() {
|
function MagicLinkContent() {
|
||||||
@@ -46,7 +47,7 @@ function MagicLinkContent() {
|
|||||||
? '/auth/claim-account'
|
? '/auth/claim-account'
|
||||||
: callbackURL;
|
: callbackURL;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(destination);
|
redirectAfterAuth(destination);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, Suspense } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -11,14 +11,20 @@ import Input from '@/components/ui/Input';
|
|||||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||||
|
import {
|
||||||
|
clearRedirectAttempt,
|
||||||
|
didRedirectBounce,
|
||||||
|
redirectAfterAuth,
|
||||||
|
} from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t, locale: language } = useLanguage();
|
const { t, locale: language } = useLanguage();
|
||||||
const { login } = useAuth();
|
const { login, user, isLoading: authLoading } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false);
|
||||||
|
const [bounced, setBounced] = useState(false);
|
||||||
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
||||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -29,6 +35,27 @@ function LoginContent() {
|
|||||||
// Check for redirect after login (only same-origin relative paths are honoured)
|
// Check for redirect after login (only same-origin relative paths are honoured)
|
||||||
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
||||||
|
|
||||||
|
// Send an already-signed-in visitor on to their destination — and detect the case
|
||||||
|
// where that destination bounced them back here, which otherwise looks like the
|
||||||
|
// login page silently ignoring a successful sign-in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || redirecting) return;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Signed out on the login page is a clean slate.
|
||||||
|
clearRedirectAttempt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didRedirectBounce(redirectTo)) {
|
||||||
|
setBounced(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(redirectTo);
|
||||||
|
}, [authLoading, redirecting, user, redirectTo]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,10 +63,12 @@ function LoginContent() {
|
|||||||
try {
|
try {
|
||||||
await login(formData.email, formData.password);
|
await login(formData.email, formData.password);
|
||||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
|
||||||
router.push(redirectTo);
|
// Deliberately leaves `loading` set: the button must stay disabled until the
|
||||||
|
// browser replaces this page.
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(redirectTo);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('auth.errors.invalidCredentials'));
|
toast.error(error.message || t('auth.errors.invalidCredentials'));
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -67,6 +96,38 @@ function LoginContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The destination sent us back here even though the session is valid. Say so, rather
|
||||||
|
// than re-showing a form that appears to do nothing. The retry link is a plain <a> so
|
||||||
|
// it is a full page load, like every other navigation out of this page.
|
||||||
|
if (bounced) {
|
||||||
|
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">
|
||||||
|
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
|
||||||
|
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
|
||||||
|
<p className="mt-4 text-sm text-gray-600">
|
||||||
|
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
|
||||||
|
</p>
|
||||||
|
<a href={redirectTo} className="mt-6 block">
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('auth.login.redirectRetry')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
|
||||||
|
>
|
||||||
|
{t('nav.home')}
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh] flex items-center">
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
@@ -151,9 +212,25 @@ function LoginContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={loading || redirecting}
|
||||||
|
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
|
||||||
|
>
|
||||||
{t('auth.login.submit')}
|
{t('auth.login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{redirecting && (
|
||||||
|
<p
|
||||||
|
className="text-center text-sm text-gray-600"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{t('auth.login.redirecting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
) : magicLinkSent ? (
|
) : magicLinkSent ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -9,13 +8,16 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||||
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const REDIRECT_TO = '/dashboard';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { t, locale: language } = useLanguage();
|
const { t, locale: language } = useLanguage();
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -30,10 +32,12 @@ export default function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
await register(formData);
|
await register(formData);
|
||||||
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
||||||
router.push('/dashboard');
|
// Deliberately leaves `loading` set: the button must stay disabled until the
|
||||||
|
// browser replaces this page.
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(REDIRECT_TO);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('auth.errors.emailExists'));
|
toast.error(error.message || t('auth.errors.emailExists'));
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -104,9 +108,25 @@ export default function RegisterPage() {
|
|||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={loading || redirecting}
|
||||||
|
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
|
||||||
|
>
|
||||||
{t('auth.register.submit')}
|
{t('auth.register.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{redirecting && (
|
||||||
|
<p
|
||||||
|
className="text-center text-sm text-gray-600"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{t('auth.login.redirecting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-600">
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||||
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -83,9 +84,10 @@ export default function GoogleSignInButton({
|
|||||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Use window.location for navigation to ensure clean state.
|
// Full page load for a clean state, recording the attempt so the login page can
|
||||||
// Constrain to a same-origin path to avoid open redirects.
|
// tell a bounced redirect from a fresh visit. Constrained to a same-origin path
|
||||||
window.location.href = safeInternalPath(redirectTo, '/dashboard');
|
// to avoid open redirects.
|
||||||
|
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard'));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/** Label shown in place of the children while loading. */
|
||||||
|
loadingText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading,
|
||||||
|
loadingText = 'Loading...',
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -62,7 +76,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Loading...
|
{loadingText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
|
|||||||
@@ -235,6 +235,9 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"submit": "Sign In",
|
"submit": "Sign In",
|
||||||
|
"redirecting": "Redirecting...",
|
||||||
|
"redirectBlocked": "You're signed in, but we couldn't open that page.",
|
||||||
|
"redirectRetry": "Try again",
|
||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
"register": "Sign Up"
|
"register": "Sign Up"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -235,6 +235,9 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Contraseña",
|
"password": "Contraseña",
|
||||||
"submit": "Iniciar Sesión",
|
"submit": "Iniciar Sesión",
|
||||||
|
"redirecting": "Redirigiendo...",
|
||||||
|
"redirectBlocked": "Has iniciado sesión, pero no pudimos abrir esa página.",
|
||||||
|
"redirectRetry": "Intentar de nuevo",
|
||||||
"noAccount": "¿No tienes cuenta?",
|
"noAccount": "¿No tienes cuenta?",
|
||||||
"register": "Registrarse"
|
"register": "Registrarse"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Post-authentication navigation.
|
||||||
|
*
|
||||||
|
* Navigation uses a full page load rather than `router.push`. The session is an httpOnly
|
||||||
|
* cookie, and only a top-level request lets the middleware guard in `middleware.ts` see it
|
||||||
|
* and re-render the header and the destination together.
|
||||||
|
*
|
||||||
|
* A marker in sessionStorage records where we last sent someone, and when. If the
|
||||||
|
* middleware cannot see the session cookie it redirects straight back to `/login`, and
|
||||||
|
* without the marker the login page would immediately navigate again — an endless reload
|
||||||
|
* loop. A React ref cannot do this job: a full page load resets component state.
|
||||||
|
*/
|
||||||
|
const ATTEMPT_KEY = 'spanglish.auth-redirect-attempt';
|
||||||
|
|
||||||
|
// A real bounce comes back within a few hundred milliseconds (it is a server redirect).
|
||||||
|
// An older marker means an unrelated later visit to /login, not a failed redirect.
|
||||||
|
const ATTEMPT_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
export function markRedirectAttempt(target: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify({ target, at: Date.now() }));
|
||||||
|
} catch {
|
||||||
|
/* storage disabled (private mode): the loop guard degrades, navigation still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRedirectAttempt(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when we just sent the user to `target` and are back on the login page instead. */
|
||||||
|
export function didRedirectBounce(target: string): boolean {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(ATTEMPT_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const { target: attempted, at } = JSON.parse(raw) as { target?: string; at?: number };
|
||||||
|
return (
|
||||||
|
attempted === target && typeof at === 'number' && Date.now() - at < ATTEMPT_TTL_MS
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the attempt, then navigates. Callers should keep their pending/loading UI on
|
||||||
|
* screen: this never resolves, the page is replaced.
|
||||||
|
*/
|
||||||
|
export function redirectAfterAuth(target: string): void {
|
||||||
|
markRedirectAttempt(target);
|
||||||
|
window.location.assign(target);
|
||||||
|
}
|
||||||
@@ -13,16 +13,25 @@ const SESSION_COOKIES = [
|
|||||||
'spanglish.session_token', // development
|
'spanglish.session_token', // development
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Matched as a fallback so a change to Better Auth's `advanced.cookiePrefix` cannot
|
||||||
|
// silently lock every user out of these routes.
|
||||||
|
const SESSION_COOKIE_SUFFIX = '.session_token';
|
||||||
|
|
||||||
|
function hasSessionCookie(request: NextRequest): boolean {
|
||||||
|
if (SESSION_COOKIES.some((name) => !!request.cookies.get(name)?.value)) return true;
|
||||||
|
return request.cookies
|
||||||
|
.getAll()
|
||||||
|
.some((cookie) => cookie.name.endsWith(SESSION_COOKIE_SUFFIX) && !!cookie.value);
|
||||||
|
}
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname, search } = request.nextUrl;
|
||||||
|
|
||||||
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
|
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
|
||||||
const hasSessionCookie = SESSION_COOKIES.some(
|
if (!hasSessionCookie(request)) {
|
||||||
(name) => !!request.cookies.get(name)?.value
|
|
||||||
);
|
|
||||||
if (!hasSessionCookie) {
|
|
||||||
const loginUrl = new URL('/login', request.url);
|
const loginUrl = new URL('/login', request.url);
|
||||||
loginUrl.searchParams.set('redirect', pathname);
|
// Keep the query string, so a bounced /admin/photos?page=3 resumes where it was.
|
||||||
|
loginUrl.searchParams.set('redirect', `${pathname}${search}`);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user