diff --git a/next.config.ts b/next.config.ts index 7ac8a173ca..3f38c5083c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -64,6 +64,34 @@ const SECURITY_HEADERS = [ ] as const; const nextConfig: NextConfig = { + /** + * Cross-origin dev access (Next.js 16). + * + * Next 16 blocks requests to dev-only resources (`/_next/*` internals, + * the HMR websocket, the dev overlay) unless the browser's Origin is + * the host the dev server booted on — `localhost` by default. Tunnels + * like ngrok serve the app from a public HTTPS host, so without + * allow-listing that host those dev requests come back 403: HMR stops + * working and the dev session degrades over the tunnel (issue #365). + * + * Wildcards match subdomains only (Next's CSRF matcher), so the + * randomised tunnel subdomain is covered. Add any other host via + * `ALLOWED_DEV_ORIGINS` (comma-separated). This key is dev-only and + * has no effect on a production build. + */ + allowedDevOrigins: [ + "*.ngrok-free.app", + "*.ngrok.app", + "*.ngrok.io", + "*.trycloudflare.com", + "*.loca.lt", + ...(process.env.ALLOWED_DEV_ORIGINS + ? process.env.ALLOWED_DEV_ORIGINS.split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + : []), + ], + /** * Cache-Control policy. * diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index b250025e2d..abef3c2d39 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Suspense, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { createClient } from "@/lib/supabase/client"; @@ -42,7 +42,6 @@ function LoginPageInner() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const router = useRouter(); const supabase = createClient(); const handleLogin = async (e: React.FormEvent) => { @@ -61,11 +60,18 @@ function LoginPageInner() { return; } - if (inviteToken) { - router.push(`/join/${encodeURIComponent(inviteToken)}`); - } else { - router.push("/dashboard"); - } + // Full-page navigation (not router.push) so the browser issues a + // fresh top-level request that carries the just-written Supabase + // auth cookies to the middleware gating /dashboard. A soft + // client-side navigation can reach the protected route before the + // server observes the new session, so the middleware bounces it + // back to /login — which looks like the page "just refreshing" + // instead of signing in (issue #365). Mirrors the deliberate full + // reload the invite-accept flow already uses in join/[token]. + const destination = inviteToken + ? `/join/${encodeURIComponent(inviteToken)}` + : "/dashboard"; + window.location.href = destination; }; return (