import createIntlMiddleware from "next-intl/middleware"; import { auth } from "@/lib/auth"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { routing } from "@/i18n/routing"; const intlMiddleware = createIntlMiddleware(routing); // One-time marker: set after we've applied the user's profile language // once following sign-in, cleared whenever the login page is shown (so // the next sign-in re-applies it). Keeps the header switcher a // per-session override rather than forcing the profile locale on every // navigation. const LOCALE_INIT_COOKIE = "pieced_locale_init"; const LOCALE_INIT_OPTS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 8 * 60 * 60, }; const publicPaths = ["/login", "/register", "/api/auth", "/api/register"]; function isPublicPath(pathname: string): boolean { // Strip locale prefix for comparison const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/"; return ( publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) || pathname.startsWith("/api/auth") || pathname.startsWith("/api/register") ); } export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // NextAuth API routes and register API pass through directly if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/register")) { return NextResponse.next(); } const stripped = pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/"; // Showing the login page resets the one-time locale marker so the // next sign-in re-applies the user's profile language. Logout // redirects here, which makes this the natural reset point. if (stripped === "/login") { const res = intlMiddleware(request); res.cookies.delete(LOCALE_INIT_COOKIE); return res; } // Auth guard for protected paths if (!isPublicPath(pathname)) { const session = await auth(); if (!session) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(loginUrl); } // One-time apply of the user's preferred language after sign-in. // Gated by LOCALE_INIT_COOKIE (cleared on the /login view), so it // fires at most once per login; afterwards the URL and the header // switcher control the locale freely. const applied = request.cookies.get(LOCALE_INIT_COOKIE)?.value === "1"; const pref = (session as { platformUser?: { locale?: string } }) .platformUser?.locale; const base = pref?.split("-")[0]; if (!applied && base && routing.locales.includes(base as never)) { const target = base === routing.defaultLocale ? stripped : `/${base}${stripped === "/" ? "" : stripped}`; if (target !== pathname) { const url = new URL(target, request.url); url.search = request.nextUrl.search; const res = NextResponse.redirect(url); res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS); return res; } // Already on the right locale — mark applied and continue. const res = intlMiddleware(request); res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS); return res; } } return intlMiddleware(request); } export const config = { // Excludes _next/* internal routes, the favicon, api routes, AND any // path containing a dot (covers all static files served from public/, // e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the // i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png") // and the file is not found. matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"], };